Skip to content

Commit 889302b

Browse files
feat: billing system overhaul (#751)
1 parent ea9934c commit 889302b

File tree

12 files changed

+454
-233
lines changed

12 files changed

+454
-233
lines changed

apps/platform/trpc/routers/orgRouter/setup/billingRouter.ts

Lines changed: 37 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -30,34 +30,22 @@ export const billingRouter = router({
3030
and(eq(orgMembers.orgId, orgId), eq(orgMembers.status, 'active'))
3131
);
3232

33+
const dates = orgBillingQuery
34+
? await billingTrpcClient.stripe.subscriptions.getSubscriptionDates.query(
35+
{
36+
orgId
37+
}
38+
)
39+
: null;
40+
3341
return {
3442
totalUsers: activeOrgMembersCount[0]?.count,
3543
currentPlan: orgPlan,
36-
currentPeriod: orgPeriod
37-
};
38-
}),
39-
getOrgStripePortalLink: eeProcedure
40-
.unstable_concat(orgAdminProcedure)
41-
.query(async ({ ctx }) => {
42-
const { org } = ctx;
43-
const orgId = org.id;
44-
45-
const orgPortalLink =
46-
await billingTrpcClient.stripe.links.getPortalLink.query({
47-
orgId: orgId
48-
});
49-
50-
if (!orgPortalLink.link) {
51-
throw new TRPCError({
52-
code: 'FORBIDDEN',
53-
message: 'Org not subscribed to a plan'
54-
});
55-
}
56-
return {
57-
portalLink: orgPortalLink.link
44+
currentPeriod: orgPeriod,
45+
dates
5846
};
5947
}),
60-
getOrgSubscriptionPaymentLink: eeProcedure
48+
createCheckoutSession: eeProcedure
6149
.unstable_concat(orgAdminProcedure)
6250
.input(
6351
z.object({
@@ -76,6 +64,7 @@ export const billingRouter = router({
7664
id: true
7765
}
7866
});
67+
7968
if (orgSubscriptionQuery?.id) {
8069
throw new TRPCError({
8170
code: 'FORBIDDEN',
@@ -93,24 +82,38 @@ export const billingRouter = router({
9382
const activeOrgMembersCount = Number(
9483
activeOrgMembersCountResponse[0]?.count ?? '0'
9584
);
96-
const orgSubLink =
97-
await billingTrpcClient.stripe.links.createSubscriptionPaymentLink.mutate(
98-
{
99-
orgId: orgId,
100-
plan: plan,
101-
period: period,
102-
totalOrgUsers: activeOrgMembersCount
103-
}
104-
);
85+
const checkoutSession =
86+
await billingTrpcClient.stripe.links.createCheckoutSession.mutate({
87+
orgId: orgId,
88+
plan: plan,
89+
period: period,
90+
totalOrgUsers: activeOrgMembersCount
91+
});
92+
93+
return {
94+
checkoutSessionId: checkoutSession.id,
95+
checkoutSessionClientSecret: checkoutSession.clientSecret
96+
};
97+
}),
98+
getOrgStripePortalLink: eeProcedure
99+
.unstable_concat(orgAdminProcedure)
100+
.mutation(async ({ ctx }) => {
101+
const { org } = ctx;
102+
const orgId = org.id;
103+
104+
const orgPortalLink =
105+
await billingTrpcClient.stripe.links.getPortalLink.query({
106+
orgId: orgId
107+
});
105108

106-
if (!orgSubLink.link) {
109+
if (!orgPortalLink.link) {
107110
throw new TRPCError({
108111
code: 'FORBIDDEN',
109112
message: 'Org not subscribed to a plan'
110113
});
111114
}
112115
return {
113-
subLink: orgSubLink.link
116+
portalLink: orgPortalLink.link
114117
};
115118
}),
116119
isPro: eeProcedure.query(async ({ ctx }) => {

apps/platform/trpc/routers/userRouter/securityRouter.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1230,6 +1230,7 @@ export const securityRouter = router({
12301230

12311231
await Promise.allSettled(
12321232
orgIdsArray.map(async (orgId) => {
1233+
// Update org user count
12331234
await refreshOrgShortcodeCache(orgId);
12341235
})
12351236
);
@@ -1242,6 +1243,16 @@ export const securityRouter = router({
12421243
status: 'removed'
12431244
})
12441245
.where(inArray(orgMembers.id, orgMemberIdsArray));
1246+
1247+
if (!ctx.selfHosted) {
1248+
await Promise.allSettled(
1249+
orgIdsArray.map(async (orgId) => {
1250+
await billingTrpcClient.stripe.subscriptions.updateOrgUserCount.mutate(
1251+
{ orgId }
1252+
);
1253+
})
1254+
);
1255+
}
12451256
}
12461257

12471258
// delete orgs
@@ -1384,7 +1395,7 @@ export const securityRouter = router({
13841395

13851396
// Delete Billing
13861397

1387-
if (env.EE_LICENSE_KEY) {
1398+
if (!ctx.selfHosted) {
13881399
await Promise.all(
13891400
orgIdsArray.map(async (orgId) => {
13901401
await billingTrpcClient.stripe.subscriptions.cancelOrgSubscription.mutate(

apps/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
"@radix-ui/react-toggle-group": "^1.1.0",
4141
"@radix-ui/react-tooltip": "^1.1.2",
4242
"@simplewebauthn/browser": "^10.0.0",
43+
"@stripe/react-stripe-js": "^2.8.0",
44+
"@stripe/stripe-js": "^4.3.0",
4345
"@t3-oss/env-core": "^0.11.0",
4446
"@tailwindcss/typography": "^0.5.14",
4547
"@tanstack/react-query": "^5.52.1",

apps/web/src/app/[orgShortcode]/settings/org/setup/billing/_components/plans-table.tsx

Lines changed: 65 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,5 @@
11
'use client';
2-
import {
3-
AlertDialog,
4-
AlertDialogContent,
5-
AlertDialogDescription,
6-
AlertDialogFooter,
7-
AlertDialogHeader,
8-
AlertDialogTitle
9-
} from '@/src/components/shadcn-ui/alert-dialog';
2+
103
import {
114
Card,
125
CardContent,
@@ -15,14 +8,29 @@ import {
158
CardHeader,
169
CardTitle
1710
} from '@/src/components/shadcn-ui/card';
11+
import {
12+
Dialog,
13+
DialogContent,
14+
DialogDescription,
15+
DialogHeader,
16+
DialogTitle
17+
} from '@/src/components/shadcn-ui/dialog';
18+
import {
19+
EmbeddedCheckoutProvider,
20+
EmbeddedCheckout
21+
} from '@stripe/react-stripe-js';
22+
import {
23+
loadStripe,
24+
type StripeEmbeddedCheckoutOptions
25+
} from '@stripe/stripe-js';
1826
import { Tabs, TabsList, TabsTrigger } from '@/src/components/shadcn-ui/tabs';
1927
import { Button } from '@/src/components/shadcn-ui/button';
20-
import { Check, SpinnerGap } from '@phosphor-icons/react';
2128
import { useOrgShortcode } from '@/src/hooks/use-params';
22-
import { useEffect, useState } from 'react';
29+
import { useCallback, useRef, useState } from 'react';
30+
import { Check } from '@phosphor-icons/react';
2331
import { platform } from '@/src/lib/trpc';
2432
import { cn } from '@/src/lib/utils';
25-
import { ms } from '@u22n/utils/ms';
33+
import { env } from '@/src/env';
2634

2735
type PricingSwitchProps = {
2836
onSwitch: (value: string) => void;
@@ -294,104 +302,60 @@ type StripeModalProps = {
294302
};
295303

296304
function StripeModal({ open, isYearly, plan, setOpen }: StripeModalProps) {
305+
if (!env.NEXT_PUBLIC_BILLING_STRIPE_PUBLISHABLE_KEY) {
306+
throw new Error(
307+
'Stripe publishable key not set, cannot render Stripe modal'
308+
);
309+
}
297310
const orgShortcode = useOrgShortcode();
298311
const utils = platform.useUtils();
312+
const stripePromise = useRef(
313+
loadStripe(env.NEXT_PUBLIC_BILLING_STRIPE_PUBLISHABLE_KEY)
314+
);
299315

300-
const {
301-
data: paymentLink,
302-
isLoading: paymentLinkLoading,
303-
error: paymentLinkError
304-
} = platform.org.setup.billing.getOrgSubscriptionPaymentLink.useQuery(
305-
{
316+
const fetchClientSecret = useCallback(
317+
() =>
318+
utils.org.setup.billing.createCheckoutSession
319+
.fetch({
320+
orgShortcode,
321+
plan,
322+
period: isYearly ? 'yearly' : 'monthly'
323+
})
324+
.then((res) => res.checkoutSessionClientSecret),
325+
[
326+
isYearly,
306327
orgShortcode,
307328
plan,
308-
period: isYearly ? 'yearly' : 'monthly'
309-
},
310-
{
311-
enabled: open
312-
}
329+
utils.org.setup.billing.createCheckoutSession
330+
]
313331
);
332+
const onComplete = useCallback(() => {
333+
setOpen(false);
334+
setTimeout(() => void utils.org.setup.billing.invalidate(), 1000);
335+
}, [setOpen, utils.org.setup.billing]);
314336

315-
const { data: overview } =
316-
platform.org.setup.billing.getOrgBillingOverview.useQuery(
317-
{ orgShortcode },
318-
{
319-
enabled: open && paymentLink && !paymentLinkLoading,
320-
refetchOnWindowFocus: true,
321-
refetchInterval: ms('15 seconds')
322-
}
323-
);
324-
325-
// Open payment link once payment link is generated
326-
useEffect(() => {
327-
if (!open || paymentLinkLoading || !paymentLink) return;
328-
window.open(paymentLink.subLink, '_blank');
329-
}, [open, paymentLink, paymentLinkLoading]);
330-
331-
// handle payment info update
332-
useEffect(() => {
333-
if (overview?.currentPlan === 'pro') {
334-
void utils.org.setup.billing.getOrgBillingOverview.invalidate({
335-
orgShortcode
336-
});
337-
setOpen(false);
338-
}
339-
}, [
340-
orgShortcode,
341-
overview,
342-
setOpen,
343-
utils.org.setup.billing.getOrgBillingOverview
344-
]);
337+
const options = {
338+
fetchClientSecret,
339+
onComplete
340+
} satisfies StripeEmbeddedCheckoutOptions;
345341

346342
return (
347-
<AlertDialog open={open}>
348-
<AlertDialogContent>
349-
<AlertDialogHeader>
350-
<AlertDialogTitle>Upgrade to Pro</AlertDialogTitle>
351-
<AlertDialogDescription className="space-y-2 p-2">
352-
{paymentLinkLoading ? (
353-
<span className="flex items-center gap-2">
354-
<SpinnerGap className="size-4 animate-spin" />
355-
Generating Payment Link
356-
</span>
357-
) : paymentLink ? (
358-
'Waiting for Payment (This may take a few seconds)'
359-
) : (
360-
<span className="text-red-9">{paymentLinkError?.message}</span>
361-
)}
362-
</AlertDialogDescription>
363-
</AlertDialogHeader>
364-
<div className="flex flex-col gap-2 p-2">
365-
<span>
366-
We are waiting for your payment to be processed. It may take a few
367-
seconds for the payment to reflect in app.
368-
</span>
369-
{paymentLink && (
370-
<span>
371-
If a new tab was not opened,{' '}
372-
<a
373-
target="_blank"
374-
href={paymentLink.subLink}
375-
className="underline">
376-
open it manually.
377-
</a>
378-
</span>
379-
)}
380-
<span>
381-
{`If your payment hasn't been detected correctly, please try refreshing
382-
the page.`}
383-
</span>
384-
<span>If the issue persists, please contact support.</span>
385-
</div>
386-
387-
<AlertDialogFooter>
388-
<Button
389-
onClick={() => setOpen(false)}
390-
className="w-full">
391-
Close
392-
</Button>
393-
</AlertDialogFooter>
394-
</AlertDialogContent>
395-
</AlertDialog>
343+
<Dialog
344+
open={open}
345+
onOpenChange={setOpen}>
346+
<DialogContent className="w-[90vw] max-w-screen-lg p-0">
347+
<DialogHeader className="sr-only">
348+
<DialogTitle>Stripe Checkout</DialogTitle>
349+
<DialogDescription>Checkout with Stripe</DialogDescription>
350+
</DialogHeader>
351+
{open && (
352+
<EmbeddedCheckoutProvider
353+
options={options}
354+
stripe={stripePromise.current}>
355+
<EmbeddedCheckout className="*:rounded-lg" />
356+
</EmbeddedCheckoutProvider>
357+
)}
358+
</DialogContent>
359+
</Dialog>
396360
);
397361
}

0 commit comments

Comments
 (0)