Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: billing system changes #755

Merged
merged 1 commit into from
Aug 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading