Skip to content

Commit

Permalink
add bank account to an app
Browse files Browse the repository at this point in the history
  • Loading branch information
devkiran committed Oct 24, 2024
1 parent ae6f445 commit fead702
Show file tree
Hide file tree
Showing 7 changed files with 326 additions and 20 deletions.
Original file line number Diff line number Diff line change
@@ -1,33 +1,49 @@
"use client";

import useWorkspace from "@/lib/swr/use-workspace";
import { useAddBankAccountModal } from "@/ui/modals/add-bank-account-modal";
import { Button, MaxWidthWrapper } from "@dub/ui";

export const BankAccountsClient = () => {
const { slug } = useWorkspace();
const { bankAccountName, maskedAccountNumber } = useWorkspace();

return (
<div className="relative min-h-[calc(100vh-16px)]">
<MaxWidthWrapper className="grid gap-5 pb-10 pt-3">
<div className="flex items-center gap-5 rounded-lg border bg-white p-5">
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-neutral-300">
<div className="h-5 w-[41px]"></div>
</div>
const { AddBankAccountModal, setShowAddBankAccountModal } =
useAddBankAccountModal();

<div className="flex grow flex-col gap-1">
<div className="text-base font-semibold text-gray-700">
Bank account
return (
<>
<AddBankAccountModal />
<div className="relative min-h-[calc(100vh-16px)]">
<MaxWidthWrapper className="grid gap-5 pb-10 pt-3">
<div className="flex items-center gap-5 rounded-lg border bg-white p-5">
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-neutral-300">
<div className="h-5 w-[41px]"></div>
</div>
<div className="text-sm text-neutral-500">
Add your bank account

<div className="flex grow flex-col gap-1">
<div className="text-base font-semibold text-gray-700">
{bankAccountName ?? "Add your bank account"}
</div>
<div className="text-sm text-neutral-500">
{maskedAccountNumber
? `*******${maskedAccountNumber}`
: "Add your bank account"}
</div>
</div>
</div>

<div>
<Button text="Add bank account" />
<div>
{bankAccountName ? (
<Button text="Disconnect" />
) : (
<Button
text="Add bank account"
onClick={() => setShowAddBankAccountModal(true)}
/>
)}
</div>
</div>
</div>
</MaxWidthWrapper>
</div>
</MaxWidthWrapper>
</div>
</>
);
};
58 changes: 58 additions & 0 deletions apps/web/lib/actions/add-bank-account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"use server";

import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { addAppAchAccount } from "../dots/add-app-ach-account";
import { createDotsApp } from "../dots/create-dots-app";
import { addBankAccountSchema } from "../dots/schemas";
import { authActionClient } from "./safe-action";

const schema = addBankAccountSchema.extend({ workspaceId: z.string() });

export const addBankAccount = authActionClient
.schema(schema)
.action(async ({ parsedInput, ctx }) => {
const { workspace } = ctx;
const { accountNumber, accountType, routingNumber } = parsedInput;

let dotsAppId: string | null = workspace.dotsAppId;

// Create Dots app if it doesn't exist
if (!dotsAppId) {
const dotsApp = await createDotsApp({ workspace });

await prisma.project.update({
where: {
id: workspace.id,
},
data: {
dotsAppId: dotsApp.id,
},
});

dotsAppId = dotsApp.id;
}

// Add bank account to Dots app
const achAccount = await addAppAchAccount({
dotsAppId,
accountNumber,
routingNumber,
accountType,
});

await prisma.project.update({
where: {
id: workspace.id,
},
data: {
bankAccountName: achAccount.name,
maskedAccountNumber: achAccount.mask,
},
});

revalidatePath("/");

return achAccount;
});
39 changes: 39 additions & 0 deletions apps/web/lib/dots/add-app-ach-account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { DOTS_API_URL } from "./env";
import { achAccountSchema } from "./schemas";
import { getBasicAuthToken } from "./utils";

export const addAppAchAccount = async ({
dotsAppId,
accountNumber,
routingNumber,
accountType,
}: {
dotsAppId: string;
accountNumber: string;
routingNumber: string;
accountType: "checking" | "savings";
}) => {
const response = await fetch(
`${DOTS_API_URL}/apps/${dotsAppId}/ach-account`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Basic ${getBasicAuthToken()}`,
},
body: JSON.stringify({
account_number: accountNumber,
routing_number: routingNumber,
account_type: accountType,
}),
},
);

if (!response.ok) {
console.error(await response.text());

throw new Error(`Failed to add ACH account to Dots app ${dotsAppId}.`);
}

return achAccountSchema.parse(await response.json());
};
11 changes: 11 additions & 0 deletions apps/web/lib/dots/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,14 @@ export const dotsAppSchema = z.object({
name: z.string(),
status: z.string(),
});

export const addBankAccountSchema = z.object({
accountNumber: z.string().min(1),
routingNumber: z.string().min(1),
accountType: z.enum(["checking", "savings"]).default("checking"),
});

export const achAccountSchema = z.object({
name: z.string(),
mask: z.string(),
});
2 changes: 2 additions & 0 deletions apps/web/lib/zod/schemas/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ export const WorkspaceSchema = z
.string()
.nullable()
.describe("The publishable key of the workspace."),
bankAccountName: z.string().nullable(),
maskedAccountNumber: z.string().nullable(),
})
.openapi({
title: "Workspace",
Expand Down
5 changes: 4 additions & 1 deletion apps/web/prisma/schema/workspace.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ model Project {
updatedAt DateTime @updatedAt
usageLastChecked DateTime @default(now())
publishableKey String? @unique
dotsAppId String? @unique
dotsAppId String? @unique
bankAccountName String?
maskedAccountNumber String?
users ProjectUsers[]
invites ProjectInvite[]
Expand Down
177 changes: 177 additions & 0 deletions apps/web/ui/modals/add-bank-account-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { addBankAccount } from "@/lib/actions/add-bank-account";
import { addBankAccountSchema } from "@/lib/dots/schemas";
import useWorkspace from "@/lib/swr/use-workspace";
import z from "@/lib/zod";
import { Button, Modal } from "@dub/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAction } from "next-safe-action/hooks";
import { useCallback, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";

interface AddBankAccountProps {
showModal: boolean;
setShowModal: (showModal: boolean) => void;
}

type BankAccount = z.infer<typeof addBankAccountSchema>;

interface AddBankAccountFormProps {
onCancel: () => void;
onSuccess: () => void;
}

const AddBankAccount = ({ showModal, setShowModal }: AddBankAccountProps) => {
return (
<Modal
showModal={showModal}
setShowModal={setShowModal}
drawerRootProps={{ repositionInputs: false }}
>
<h3 className="border-b border-gray-200 px-4 py-4 text-lg font-medium sm:px-6">
Add bank account
</h3>
<div className="scrollbar-hide mt-6 max-h-[calc(100dvh-200px)] overflow-auto overflow-y-scroll">
<AddBankAccountForm
onSuccess={() => setShowModal(false)}
onCancel={() => setShowModal(false)}
/>
</div>
</Modal>
);
};

const AddBankAccountForm = ({
onCancel,
onSuccess,
}: AddBankAccountFormProps) => {
const { id: workspaceId } = useWorkspace();

const {
register,
handleSubmit,
formState: { isValid, isSubmitting },
} = useForm<BankAccount>({
resolver: zodResolver(addBankAccountSchema),
});

const { executeAsync, isExecuting } = useAction(addBankAccount, {
async onSuccess() {
toast.success(
"Bank account added successfully. Waiting for verification.",
);
onSuccess();
},
onError({ error }) {
toast.error(error.serverError?.serverError);
},
});

const onSubmit = async (data: BankAccount) => {
await executeAsync({ ...data, workspaceId: workspaceId! });
};

return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-y-6 px-4 text-left sm:px-6">
<div className="flex flex-col gap-3">
<div>
<label
htmlFor="accountNumber"
className="flex items-center space-x-2"
>
<h2 className="text-sm font-medium text-gray-900">
Account number
</h2>
</label>
<div className="relative mt-2 rounded-md shadow-sm">
<input
{...register("accountNumber")}
className="block w-full rounded-md border-gray-300 text-gray-900 placeholder-gray-400 focus:border-gray-500 focus:outline-none focus:ring-gray-500 sm:text-sm"
required
autoFocus
autoComplete="off"
/>
</div>
</div>

<div>
<label
htmlFor="routingNumber"
className="flex items-center space-x-2"
>
<h2 className="text-sm font-medium text-gray-900">
Routing number
</h2>
</label>
<div className="relative mt-2 rounded-md shadow-sm">
<input
{...register("routingNumber")}
className="block w-full rounded-md border-gray-300 text-gray-900 placeholder-gray-400 focus:border-gray-500 focus:outline-none focus:ring-gray-500 sm:text-sm"
required
autoComplete="off"
/>
</div>
</div>

<div>
<label
htmlFor="accountType"
className="flex items-center space-x-2"
>
<h2 className="text-sm font-medium text-gray-900">
Account type
</h2>
</label>
<div className="relative mt-2 rounded-md shadow-sm">
<select
{...register("accountType")}
className="block w-full rounded-md border-gray-300 text-gray-900 focus:border-gray-500 focus:outline-none focus:ring-gray-500 sm:text-sm"
required
>
<option value="checking">Checking</option>
<option value="savings">Savings</option>
</select>
</div>
</div>
</div>
</div>

<div className="mt-8 flex justify-end gap-2 border-t border-gray-200 px-4 py-4 sm:px-6">
<Button
type="button"
variant="secondary"
text="Cancel"
className="h-9 w-fit"
onClick={onCancel}
/>

<Button
type="submit"
text="Add bank account"
className="h-9 w-fit"
disabled={!isValid}
loading={isSubmitting || isExecuting}
/>
</div>
</form>
);
};

export function useAddBankAccountModal() {
const [showAddBankAccountModal, setShowAddBankAccountModal] = useState(false);

const AddBankAccountModal = useCallback(() => {
return (
<AddBankAccount
showModal={showAddBankAccountModal}
setShowModal={setShowAddBankAccountModal}
/>
);
}, [showAddBankAccountModal, setShowAddBankAccountModal]);

return useMemo(
() => ({ setShowAddBankAccountModal, AddBankAccountModal }),
[setShowAddBankAccountModal, AddBankAccountModal],
);
}

0 comments on commit fead702

Please sign in to comment.