From 3f9094e95dcdcae945c3e0ce235b87e735a0d63a Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Mon, 25 Aug 2025 22:35:21 +1000 Subject: [PATCH] feat: make billing better (#203) --- apps/web/package.json | 5 +- .../migration.sql | 8 + apps/web/prisma/schema.prisma | 1 + .../(dashboard)/contacts/add-contact-book.tsx | 29 +- .../src/app/(dashboard)/dasboard-layout.tsx | 2 + .../app/(dashboard)/domains/add-domain.tsx | 25 +- .../settings/team/invite-team-member.tsx | 41 ++- .../src/components/payments/PlanDetails.tsx | 1 + .../src/components/payments/UpgradeModal.tsx | 67 ++++ apps/web/src/env.js | 4 + apps/web/src/lib/constants/plans.ts | 34 ++ apps/web/src/lib/usage.ts | 2 +- apps/web/src/server/api/root.ts | 2 + apps/web/src/server/api/routers/billing.ts | 44 +-- apps/web/src/server/api/routers/contacts.ts | 64 +--- apps/web/src/server/api/routers/limits.ts | 28 ++ apps/web/src/server/api/routers/team.ts | 269 ++-------------- apps/web/src/server/billing/payments.ts | 27 +- .../server/service/contact-book-service.ts | 83 +++++ apps/web/src/server/service/domain-service.ts | 19 +- apps/web/src/server/service/limit-service.ts | 160 ++++++++++ apps/web/src/server/service/team-service.ts | 293 ++++++++++++++++++ apps/web/src/server/service/usage-service.ts | 63 ++++ apps/web/src/store/upgradeModalStore.ts | 20 ++ pnpm-lock.yaml | 25 ++ 25 files changed, 956 insertions(+), 360 deletions(-) create mode 100644 apps/web/prisma/migrations/20250824131647_add_price_ids_array/migration.sql create mode 100644 apps/web/src/components/payments/UpgradeModal.tsx create mode 100644 apps/web/src/lib/constants/plans.ts create mode 100644 apps/web/src/server/api/routers/limits.ts create mode 100644 apps/web/src/server/service/contact-book-service.ts create mode 100644 apps/web/src/server/service/limit-service.ts create mode 100644 apps/web/src/server/service/team-service.ts create mode 100644 apps/web/src/server/service/usage-service.ts create mode 100644 apps/web/src/store/upgradeModalStore.ts diff --git a/apps/web/package.json b/apps/web/package.json index 55dad1d..318a881 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,7 +36,6 @@ "@trpc/server": "^11.1.1", "@unsend/email-editor": "workspace:*", "@unsend/ui": "workspace:*", - "jsx-email": "^2.7.1", "bullmq": "^5.51.1", "chrono-node": "^2.8.0", "date-fns": "^4.1.0", @@ -45,6 +44,7 @@ "hono": "^4.7.7", "html-to-text": "^9.0.5", "ioredis": "^5.6.1", + "jsx-email": "^2.7.1", "lucide-react": "^0.503.0", "mime-types": "^3.0.1", "nanoid": "^5.1.5", @@ -67,7 +67,8 @@ "ua-parser-js": "^2.0.3", "unsend": "workspace:*", "use-debounce": "^10.0.4", - "zod": "^3.24.3" + "zod": "^3.24.3", + "zustand": "^5.0.8" }, "devDependencies": { "@next/eslint-plugin-next": "^15.3.1", diff --git a/apps/web/prisma/migrations/20250824131647_add_price_ids_array/migration.sql b/apps/web/prisma/migrations/20250824131647_add_price_ids_array/migration.sql new file mode 100644 index 0000000..f136c1c --- /dev/null +++ b/apps/web/prisma/migrations/20250824131647_add_price_ids_array/migration.sql @@ -0,0 +1,8 @@ +-- CreateEnum +CREATE TYPE "SendingDisabledReason" AS ENUM ('FREE_LIMIT_REACHED', 'BILLING_ISSUE', 'SPAM'); + +-- AlterTable +ALTER TABLE "Subscription" ADD COLUMN "priceIds" TEXT[]; + +-- Populate priceIds array with existing priceId values +UPDATE "Subscription" SET "priceIds" = ARRAY["priceId"] WHERE "priceId" IS NOT NULL; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index ffa2f4e..8d380de 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -138,6 +138,7 @@ model Subscription { teamId Int status String priceId String + priceIds String[] currentPeriodEnd DateTime? currentPeriodStart DateTime? cancelAtPeriodEnd DateTime? diff --git a/apps/web/src/app/(dashboard)/contacts/add-contact-book.tsx b/apps/web/src/app/(dashboard)/contacts/add-contact-book.tsx index 7c005ee..e9ccdfd 100644 --- a/apps/web/src/app/(dashboard)/contacts/add-contact-book.tsx +++ b/apps/web/src/app/(dashboard)/contacts/add-contact-book.tsx @@ -26,6 +26,8 @@ import { FormLabel, FormMessage, } from "@unsend/ui/src/form"; +import { useUpgradeModalStore } from "~/store/upgradeModalStore"; +import { LimitReason } from "~/lib/constants/plans"; const contactBookSchema = z.object({ name: z.string({ required_error: "Name is required" }).min(1, { @@ -38,6 +40,11 @@ export default function AddContactBook() { const createContactBookMutation = api.contacts.createContactBook.useMutation(); + const limitsQuery = api.limits.get.useQuery({ + type: LimitReason.CONTACT_BOOK, + }); + const { openModal } = useUpgradeModalStore((s) => s.action); + const utils = api.useUtils(); const contactBookForm = useForm>({ @@ -48,6 +55,11 @@ export default function AddContactBook() { }); function handleSave(values: z.infer) { + if (limitsQuery.data?.isLimitReached) { + openModal(limitsQuery.data.reason); + return; + } + createContactBookMutation.mutate( { name: values.name, @@ -59,14 +71,23 @@ export default function AddContactBook() { setOpen(false); toast.success("Contact book created successfully"); }, - } + }, ); } + function onOpenChange(_open: boolean) { + if (_open && limitsQuery.data?.isLimitReached) { + openModal(limitsQuery.data.reason); + return; + } + + setOpen(_open); + } + return ( (_open !== open ? setOpen(_open) : null)} + onOpenChange={(_open) => (_open !== open ? onOpenChange(_open) : null)} > diff --git a/apps/web/src/app/(dashboard)/settings/team/invite-team-member.tsx b/apps/web/src/app/(dashboard)/settings/team/invite-team-member.tsx index 24f6fb1..62cbb06 100644 --- a/apps/web/src/app/(dashboard)/settings/team/invite-team-member.tsx +++ b/apps/web/src/app/(dashboard)/settings/team/invite-team-member.tsx @@ -34,6 +34,8 @@ import { } from "@unsend/ui/src/form"; import { useTeam } from "~/providers/team-context"; import { isCloud, isSelfHosted } from "~/utils/common"; +import { useUpgradeModalStore } from "~/store/upgradeModalStore"; +import { LimitReason } from "~/lib/constants/plans"; const inviteTeamMemberSchema = z.object({ email: z @@ -50,6 +52,11 @@ export default function InviteTeamMember() { const { currentIsAdmin } = useTeam(); const { data: domains } = api.domain.domains.useQuery(); + const limitsQuery = api.limits.get.useQuery({ + type: LimitReason.TEAM_MEMBER, + }); + const { openModal } = useUpgradeModalStore((s) => s.action); + const [open, setOpen] = useState(false); const form = useForm({ @@ -65,6 +72,11 @@ export default function InviteTeamMember() { const createInvite = api.team.createTeamInvite.useMutation(); function onSubmit(values: FormData) { + if (limitsQuery.data?.isLimitReached) { + openModal(limitsQuery.data.reason); + return; + } + createInvite.mutate( { email: values.email, @@ -82,11 +94,16 @@ export default function InviteTeamMember() { console.error(error); toast.error(error.message || "Failed to send invitation"); }, - } + }, ); } async function onCopyLink() { + if (limitsQuery.data?.isLimitReached) { + openModal(limitsQuery.data.reason); + return; + } + createInvite.mutate( { email: form.getValues("email"), @@ -97,7 +114,7 @@ export default function InviteTeamMember() { onSuccess: (invite) => { void utils.team.getTeamInvites.invalidate(); navigator.clipboard.writeText( - `${location.origin}/join-team?inviteId=${invite.id}` + `${location.origin}/join-team?inviteId=${invite.id}`, ); form.reset(); setOpen(false); @@ -107,16 +124,28 @@ export default function InviteTeamMember() { console.error(error); toast.error(error.message || "Failed to copy invitation link"); }, - } + }, ); } + function onOpenChange(_open: boolean) { + if (_open && limitsQuery.data?.isLimitReached) { + openModal(limitsQuery.data.reason); + return; + } + + setOpen(_open); + } + if (!currentIsAdmin) { return null; } return ( - + (_open !== open ? onOpenChange(_open) : null)} + > {isSelfHosted() ? (