From fead70230419ba95ee7db1c69ae7cbe50679246e Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 24 Oct 2024 16:01:07 +0530 Subject: [PATCH] add bank account to an app --- .../[slug]/bank-accounts/page-client.tsx | 54 ++++-- apps/web/lib/actions/add-bank-account.ts | 58 ++++++ apps/web/lib/dots/add-app-ach-account.ts | 39 ++++ apps/web/lib/dots/schemas.ts | 11 ++ apps/web/lib/zod/schemas/workspaces.ts | 2 + apps/web/prisma/schema/workspace.prisma | 5 +- apps/web/ui/modals/add-bank-account-modal.tsx | 177 ++++++++++++++++++ 7 files changed, 326 insertions(+), 20 deletions(-) create mode 100644 apps/web/lib/actions/add-bank-account.ts create mode 100644 apps/web/lib/dots/add-app-ach-account.ts create mode 100644 apps/web/ui/modals/add-bank-account-modal.tsx diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/bank-accounts/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/bank-accounts/page-client.tsx index 7b8f193dc9..4f637195b6 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/bank-accounts/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/bank-accounts/page-client.tsx @@ -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 ( -
- -
-
-
-
+ const { AddBankAccountModal, setShowAddBankAccountModal } = + useAddBankAccountModal(); -
-
- Bank account + return ( + <> + +
+ +
+
+
-
- Add your bank account + +
+
+ {bankAccountName ?? "Add your bank account"} +
+
+ {maskedAccountNumber + ? `*******${maskedAccountNumber}` + : "Add your bank account"} +
-
-
-
-
- -
+ +
+ ); }; diff --git a/apps/web/lib/actions/add-bank-account.ts b/apps/web/lib/actions/add-bank-account.ts new file mode 100644 index 0000000000..fec6aab59b --- /dev/null +++ b/apps/web/lib/actions/add-bank-account.ts @@ -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; + }); diff --git a/apps/web/lib/dots/add-app-ach-account.ts b/apps/web/lib/dots/add-app-ach-account.ts new file mode 100644 index 0000000000..e3185da9de --- /dev/null +++ b/apps/web/lib/dots/add-app-ach-account.ts @@ -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()); +}; diff --git a/apps/web/lib/dots/schemas.ts b/apps/web/lib/dots/schemas.ts index f07f0b1598..4b5043446b 100644 --- a/apps/web/lib/dots/schemas.ts +++ b/apps/web/lib/dots/schemas.ts @@ -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(), +}); diff --git a/apps/web/lib/zod/schemas/workspaces.ts b/apps/web/lib/zod/schemas/workspaces.ts index 4a4ef5d53e..1915717b96 100644 --- a/apps/web/lib/zod/schemas/workspaces.ts +++ b/apps/web/lib/zod/schemas/workspaces.ts @@ -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", diff --git a/apps/web/prisma/schema/workspace.prisma b/apps/web/prisma/schema/workspace.prisma index d356691582..8157bd525f 100644 --- a/apps/web/prisma/schema/workspace.prisma +++ b/apps/web/prisma/schema/workspace.prisma @@ -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[] diff --git a/apps/web/ui/modals/add-bank-account-modal.tsx b/apps/web/ui/modals/add-bank-account-modal.tsx new file mode 100644 index 0000000000..fab2eb2627 --- /dev/null +++ b/apps/web/ui/modals/add-bank-account-modal.tsx @@ -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; + +interface AddBankAccountFormProps { + onCancel: () => void; + onSuccess: () => void; +} + +const AddBankAccount = ({ showModal, setShowModal }: AddBankAccountProps) => { + return ( + +

+ Add bank account +

+
+ setShowModal(false)} + onCancel={() => setShowModal(false)} + /> +
+
+ ); +}; + +const AddBankAccountForm = ({ + onCancel, + onSuccess, +}: AddBankAccountFormProps) => { + const { id: workspaceId } = useWorkspace(); + + const { + register, + handleSubmit, + formState: { isValid, isSubmitting }, + } = useForm({ + 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 ( +
+
+
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+
+ +
+
+
+ ); +}; + +export function useAddBankAccountModal() { + const [showAddBankAccountModal, setShowAddBankAccountModal] = useState(false); + + const AddBankAccountModal = useCallback(() => { + return ( + + ); + }, [showAddBankAccountModal, setShowAddBankAccountModal]); + + return useMemo( + () => ({ setShowAddBankAccountModal, AddBankAccountModal }), + [setShowAddBankAccountModal, AddBankAccountModal], + ); +}