Skip to content

Commit

Permalink
feat: billing system overhaul
Browse files Browse the repository at this point in the history
  • Loading branch information
BlankParticle committed Aug 28, 2024
1 parent c341627 commit 59c7698
Show file tree
Hide file tree
Showing 12 changed files with 454 additions and 233 deletions.
71 changes: 37 additions & 34 deletions apps/platform/trpc/routers/orgRouter/setup/billingRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,34 +30,22 @@ export const billingRouter = router({
and(eq(orgMembers.orgId, orgId), eq(orgMembers.status, 'active'))
);

const dates = orgBillingQuery
? await billingTrpcClient.stripe.subscriptions.getSubscriptionDates.query(
{
orgId
}
)
: null;

return {
totalUsers: activeOrgMembersCount[0]?.count,
currentPlan: orgPlan,
currentPeriod: orgPeriod
};
}),
getOrgStripePortalLink: eeProcedure
.unstable_concat(orgAdminProcedure)
.query(async ({ ctx }) => {
const { org } = ctx;
const orgId = org.id;

const orgPortalLink =
await billingTrpcClient.stripe.links.getPortalLink.query({
orgId: orgId
});

if (!orgPortalLink.link) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Org not subscribed to a plan'
});
}
return {
portalLink: orgPortalLink.link
currentPeriod: orgPeriod,
dates
};
}),
getOrgSubscriptionPaymentLink: eeProcedure
createCheckoutSession: eeProcedure
.unstable_concat(orgAdminProcedure)
.input(
z.object({
Expand All @@ -76,6 +64,7 @@ export const billingRouter = router({
id: true
}
});

if (orgSubscriptionQuery?.id) {
throw new TRPCError({
code: 'FORBIDDEN',
Expand All @@ -93,24 +82,38 @@ export const billingRouter = router({
const activeOrgMembersCount = Number(
activeOrgMembersCountResponse[0]?.count ?? '0'
);
const orgSubLink =
await billingTrpcClient.stripe.links.createSubscriptionPaymentLink.mutate(
{
orgId: orgId,
plan: plan,
period: period,
totalOrgUsers: activeOrgMembersCount
}
);
const checkoutSession =
await billingTrpcClient.stripe.links.createCheckoutSession.mutate({
orgId: orgId,
plan: plan,
period: period,
totalOrgUsers: activeOrgMembersCount
});

return {
checkoutSessionId: checkoutSession.id,
checkoutSessionClientSecret: checkoutSession.clientSecret
};
}),
getOrgStripePortalLink: eeProcedure
.unstable_concat(orgAdminProcedure)
.mutation(async ({ ctx }) => {
const { org } = ctx;
const orgId = org.id;

const orgPortalLink =
await billingTrpcClient.stripe.links.getPortalLink.query({
orgId: orgId
});

if (!orgSubLink.link) {
if (!orgPortalLink.link) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Org not subscribed to a plan'
});
}
return {
subLink: orgSubLink.link
portalLink: orgPortalLink.link
};
}),
isPro: eeProcedure.query(async ({ ctx }) => {
Expand Down
13 changes: 12 additions & 1 deletion apps/platform/trpc/routers/userRouter/securityRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1230,6 +1230,7 @@ export const securityRouter = router({

await Promise.allSettled(
orgIdsArray.map(async (orgId) => {
// Update org user count
await refreshOrgShortcodeCache(orgId);
})
);
Expand All @@ -1242,6 +1243,16 @@ export const securityRouter = router({
status: 'removed'
})
.where(inArray(orgMembers.id, orgMemberIdsArray));

if (!ctx.selfHosted) {
await Promise.allSettled(
orgIdsArray.map(async (orgId) => {
await billingTrpcClient.stripe.subscriptions.updateOrgUserCount.mutate(
{ orgId }
);
})
);
}
}

// delete orgs
Expand Down Expand Up @@ -1384,7 +1395,7 @@ export const securityRouter = router({

// Delete Billing

if (env.EE_LICENSE_KEY) {
if (!ctx.selfHosted) {
await Promise.all(
orgIdsArray.map(async (orgId) => {
await billingTrpcClient.stripe.subscriptions.cancelOrgSubscription.mutate(
Expand Down
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@simplewebauthn/browser": "^10.0.0",
"@stripe/react-stripe-js": "^2.8.0",
"@stripe/stripe-js": "^4.3.0",
"@t3-oss/env-core": "^0.11.0",
"@tailwindcss/typography": "^0.5.14",
"@tanstack/react-query": "^5.52.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
'use client';
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/src/components/shadcn-ui/alert-dialog';

import {
Card,
CardContent,
Expand All @@ -15,14 +8,29 @@ import {
CardHeader,
CardTitle
} from '@/src/components/shadcn-ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/src/components/shadcn-ui/dialog';
import {
EmbeddedCheckoutProvider,
EmbeddedCheckout
} from '@stripe/react-stripe-js';
import {
loadStripe,
type StripeEmbeddedCheckoutOptions
} from '@stripe/stripe-js';
import { Tabs, TabsList, TabsTrigger } from '@/src/components/shadcn-ui/tabs';
import { Button } from '@/src/components/shadcn-ui/button';
import { Check, SpinnerGap } from '@phosphor-icons/react';
import { useOrgShortcode } from '@/src/hooks/use-params';
import { useEffect, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { Check } from '@phosphor-icons/react';
import { platform } from '@/src/lib/trpc';
import { cn } from '@/src/lib/utils';
import { ms } from '@u22n/utils/ms';
import { env } from '@/src/env';

type PricingSwitchProps = {
onSwitch: (value: string) => void;
Expand Down Expand Up @@ -294,104 +302,60 @@ type StripeModalProps = {
};

function StripeModal({ open, isYearly, plan, setOpen }: StripeModalProps) {
if (!env.NEXT_PUBLIC_BILLING_STRIPE_PUBLISHABLE_KEY) {
throw new Error(
'Stripe publishable key not set, cannot render Stripe modal'
);
}
const orgShortcode = useOrgShortcode();
const utils = platform.useUtils();
const stripePromise = useRef(
loadStripe(env.NEXT_PUBLIC_BILLING_STRIPE_PUBLISHABLE_KEY)
);

const {
data: paymentLink,
isLoading: paymentLinkLoading,
error: paymentLinkError
} = platform.org.setup.billing.getOrgSubscriptionPaymentLink.useQuery(
{
const fetchClientSecret = useCallback(
() =>
utils.org.setup.billing.createCheckoutSession
.fetch({
orgShortcode,
plan,
period: isYearly ? 'yearly' : 'monthly'
})
.then((res) => res.checkoutSessionClientSecret),
[
isYearly,
orgShortcode,
plan,
period: isYearly ? 'yearly' : 'monthly'
},
{
enabled: open
}
utils.org.setup.billing.createCheckoutSession
]
);
const onComplete = useCallback(() => {
setOpen(false);
setTimeout(() => void utils.org.setup.billing.invalidate(), 1000);
}, [setOpen, utils.org.setup.billing]);

const { data: overview } =
platform.org.setup.billing.getOrgBillingOverview.useQuery(
{ orgShortcode },
{
enabled: open && paymentLink && !paymentLinkLoading,
refetchOnWindowFocus: true,
refetchInterval: ms('15 seconds')
}
);

// Open payment link once payment link is generated
useEffect(() => {
if (!open || paymentLinkLoading || !paymentLink) return;
window.open(paymentLink.subLink, '_blank');
}, [open, paymentLink, paymentLinkLoading]);

// handle payment info update
useEffect(() => {
if (overview?.currentPlan === 'pro') {
void utils.org.setup.billing.getOrgBillingOverview.invalidate({
orgShortcode
});
setOpen(false);
}
}, [
orgShortcode,
overview,
setOpen,
utils.org.setup.billing.getOrgBillingOverview
]);
const options = {
fetchClientSecret,
onComplete
} satisfies StripeEmbeddedCheckoutOptions;

return (
<AlertDialog open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Upgrade to Pro</AlertDialogTitle>
<AlertDialogDescription className="space-y-2 p-2">
{paymentLinkLoading ? (
<span className="flex items-center gap-2">
<SpinnerGap className="size-4 animate-spin" />
Generating Payment Link
</span>
) : paymentLink ? (
'Waiting for Payment (This may take a few seconds)'
) : (
<span className="text-red-9">{paymentLinkError?.message}</span>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex flex-col gap-2 p-2">
<span>
We are waiting for your payment to be processed. It may take a few
seconds for the payment to reflect in app.
</span>
{paymentLink && (
<span>
If a new tab was not opened,{' '}
<a
target="_blank"
href={paymentLink.subLink}
className="underline">
open it manually.
</a>
</span>
)}
<span>
{`If your payment hasn't been detected correctly, please try refreshing
the page.`}
</span>
<span>If the issue persists, please contact support.</span>
</div>

<AlertDialogFooter>
<Button
onClick={() => setOpen(false)}
className="w-full">
Close
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog
open={open}
onOpenChange={setOpen}>
<DialogContent className="w-[90vw] max-w-screen-lg p-0">
<DialogHeader className="sr-only">
<DialogTitle>Stripe Checkout</DialogTitle>
<DialogDescription>Checkout with Stripe</DialogDescription>
</DialogHeader>
{open && (
<EmbeddedCheckoutProvider
options={options}
stripe={stripePromise.current}>
<EmbeddedCheckout className="*:rounded-lg" />
</EmbeddedCheckoutProvider>
)}
</DialogContent>
</Dialog>
);
}
Loading

0 comments on commit 59c7698

Please sign in to comment.