feat: add stripe sync (#2877)

This commit is contained in:
David Nguyen
2026-06-01 18:17:16 +10:00
committed by GitHub
parent a7713f7228
commit 4bda501d51
10 changed files with 292 additions and 10 deletions
+8 -1
View File
@@ -73,5 +73,12 @@ if [ -z "$NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET" ]; then
echo "╚═════════════════════════════════════════════════════════════════════╝"
fi
NEXT_PUBLIC_WEBAPP_URL=$(load_env_var "NEXT_PUBLIC_WEBAPP_URL")
if [ -z "$NEXT_PUBLIC_WEBAPP_URL" ]; then
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
echo "[INFO]: NEXT_PUBLIC_WEBAPP_URL not set, defaulting to $NEXT_PUBLIC_WEBAPP_URL"
fi
echo "[INFO]: Starting Stripe webhook listener..."
stripe listen --api-key "$NEXT_PRIVATE_STRIPE_API_KEY" --forward-to http://localhost:3000/api/stripe/webhook
stripe listen --api-key "$NEXT_PRIVATE_STRIPE_API_KEY" --forward-to "$NEXT_PUBLIC_WEBAPP_URL/api/stripe/webhook"
@@ -0,0 +1,155 @@
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
export type AdminOrganisationSyncSubscriptionDialogProps = {
organisationId: string;
trigger?: React.ReactNode;
};
const ZAdminOrganisationSyncSubscriptionFormSchema = z.object({
syncClaims: z.boolean(),
});
type TAdminOrganisationSyncSubscriptionFormSchema = z.infer<typeof ZAdminOrganisationSyncSubscriptionFormSchema>;
export const AdminOrganisationSyncSubscriptionDialog = ({
organisationId,
trigger,
}: AdminOrganisationSyncSubscriptionDialogProps) => {
const [open, setOpen] = useState(false);
const { t } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const form = useForm<TAdminOrganisationSyncSubscriptionFormSchema>({
resolver: zodResolver(ZAdminOrganisationSyncSubscriptionFormSchema),
defaultValues: {
syncClaims: false,
},
});
const { mutateAsync: syncSubscription } = trpc.admin.organisation.subscription.sync.useMutation();
const onFormSubmit = async (values: TAdminOrganisationSyncSubscriptionFormSchema) => {
try {
await syncSubscription({
organisationId,
syncClaims: values.syncClaims,
});
toast({
title: t`Subscription synced`,
description: t`The organisation subscription has been synced with Stripe.`,
duration: 5000,
});
await navigate(0);
setOpen(false);
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`Failed to sync subscription`,
description: error.message,
variant: 'destructive',
duration: 10000,
});
}
};
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline">
<Trans>Sync Stripe subscription</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Sync Stripe subscription</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Fetch the latest subscription data from Stripe and apply it to this organisation.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="syncClaims"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0">
<FormControl>
<Checkbox
id="admin-sync-subscription-sync-claims"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<label
htmlFor="admin-sync-subscription-sync-claims"
className="font-normal text-muted-foreground text-sm leading-snug"
>
<Trans>
Sync claims. This will overwrite the current claim with the one resolved from the Stripe
subscription.
</Trans>
</label>
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Sync</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -67,7 +67,7 @@ export const AdminSwapSubscriptionDialog = ({
const selectedOrg = eligibleOrgs.find((org) => org.id === selectedOrgId);
const { mutateAsync: swapSubscription } = trpc.admin.organisation.swapSubscription.useMutation();
const { mutateAsync: swapSubscription } = trpc.admin.organisation.subscription.swap.useMutation();
const onSubmit = async () => {
if (!selectedOrgId) {
@@ -14,7 +14,7 @@ export const OrganisationUsageResetButton = ({ organisationId, counter }: Organi
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { mutateAsync: reset, isPending } = trpc.admin.organisation.resetMonthlyStat.useMutation({
const { mutateAsync: reset, isPending } = trpc.admin.organisation.stats.reset.useMutation({
onSuccess: async () => {
toast({ title: t`Counter reset.` });
await revalidate();
@@ -41,7 +41,7 @@ export const AdminOrganisationStatsTable = () => {
const orderByColumn = parseOrderByColumn(searchParams?.get('orderByColumn') ?? undefined);
const orderByDirection = parseOrderByDirection(searchParams?.get('orderByDirection') ?? undefined);
const { data, isLoading, isLoadingError } = trpc.admin.organisation.findStats.useQuery({
const { data, isLoading, isLoadingError } = trpc.admin.organisation.stats.find.useQuery({
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
@@ -40,6 +40,7 @@ import type { z } from 'zod';
import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organisation-delete-dialog';
import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
import { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-dialog';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
import { ClaimLimitFields } from '~/components/general/claim-limit-fields';
@@ -377,7 +378,16 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
)}
{organisation.subscription && (
<div>
<div className="flex flex-col gap-2 sm:flex-row">
<AdminOrganisationSyncSubscriptionDialog
organisationId={organisationId}
trigger={
<Button variant="outline">
<Trans>Sync Stripe subscription</Trans>
</Button>
}
/>
<Button variant="outline" asChild>
<Link
target="_blank"
@@ -8,6 +8,13 @@ import { match } from 'ts-pattern';
export type OnSubscriptionUpdatedOptions = {
subscription: Stripe.Subscription;
previousAttributes: Partial<Stripe.Subscription> | null;
/**
* When true, the organisationClaim will not be synced.
*
* Used by the admin sync route to update only the Subscription
* row while leaving claim entitlements untouched.
*/
bypassClaimUpdate?: boolean;
};
type StripeWebhookResponse = {
@@ -15,7 +22,11 @@ type StripeWebhookResponse = {
message: string;
};
export const onSubscriptionUpdated = async ({ subscription, previousAttributes }: OnSubscriptionUpdatedOptions) => {
export const onSubscriptionUpdated = async ({
subscription,
previousAttributes,
bypassClaimUpdate = false,
}: OnSubscriptionUpdatedOptions) => {
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
// Todo: logging
@@ -121,7 +132,8 @@ export const onSubscriptionUpdated = async ({ subscription, previousAttributes }
});
// Override current organisation claim if new one is found.
if (newClaimFound) {
// Skipped when bypassClaimUpdate is set.
if (!bypassClaimUpdate && newClaimFound) {
await tx.organisationClaim.update({
where: {
id: organisation.organisationClaim.id,
+9 -3
View File
@@ -32,6 +32,7 @@ import { resetOrganisationMonthlyStatRoute } from './reset-organisation-monthly-
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
import { resyncLicenseRoute } from './resync-license';
import { swapOrganisationSubscriptionRoute } from './swap-organisation-subscription';
import { syncOrganisationSubscriptionRoute } from './sync-organisation-subscription';
import { updateAdminOrganisationRoute } from './update-admin-organisation';
import { updateOrganisationMemberRoleRoute } from './update-organisation-member-role';
import { updateRecipientRoute } from './update-recipient';
@@ -46,9 +47,14 @@ export const adminRouter = router({
create: createAdminOrganisationRoute,
update: updateAdminOrganisationRoute,
delete: deleteOrganisationRoute,
swapSubscription: swapOrganisationSubscriptionRoute,
resetMonthlyStat: resetOrganisationMonthlyStatRoute,
findStats: findOrganisationStatsRoute,
subscription: {
swap: swapOrganisationSubscriptionRoute,
sync: syncOrganisationSubscriptionRoute,
},
stats: {
find: findOrganisationStatsRoute,
reset: resetOrganisationMonthlyStatRoute,
},
},
organisationMember: {
promoteToOwner: promoteMemberToOwnerRoute,
@@ -0,0 +1,81 @@
import { onSubscriptionUpdated } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZSyncOrganisationSubscriptionRequestSchema,
ZSyncOrganisationSubscriptionResponseSchema,
} from './sync-organisation-subscription.types';
export const syncOrganisationSubscriptionRoute = adminProcedure
.input(ZSyncOrganisationSubscriptionRequestSchema)
.output(ZSyncOrganisationSubscriptionResponseSchema)
.mutation(async ({ input, ctx }) => {
const { organisationId, syncClaims } = input;
ctx.logger.info({
input: {
organisationId,
syncClaims,
},
});
const organisation = await prisma.organisation.findUnique({
where: { id: organisationId },
include: {
subscription: true,
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
if (!organisation.subscription) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Organisation has no subscription to sync',
});
}
let stripeSubscription: Stripe.Subscription;
try {
stripeSubscription = await stripe.subscriptions.retrieve(organisation.subscription.planId, {
expand: ['items.data.price.product'],
});
} catch (error) {
if (error instanceof Stripe.errors.StripeInvalidRequestError && error.code === 'resource_missing') {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Subscription not found on Stripe',
});
}
throw error;
}
const stripeCustomerId =
typeof stripeSubscription.customer === 'string' ? stripeSubscription.customer : stripeSubscription.customer.id;
if (organisation.customerId !== stripeCustomerId) {
ctx.logger.error({
message: 'Organisation customerId does not match Stripe subscription customer',
organisationId,
localCustomerId: organisation.customerId,
stripeCustomerId,
});
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Organisation customerId mismatch: local=${organisation.customerId ?? 'null'}, Stripe=${stripeCustomerId}`,
});
}
await onSubscriptionUpdated({
subscription: stripeSubscription,
previousAttributes: null,
bypassClaimUpdate: !syncClaims,
});
});
@@ -0,0 +1,11 @@
import { z } from 'zod';
export const ZSyncOrganisationSubscriptionRequestSchema = z.object({
organisationId: z.string(),
syncClaims: z.boolean(),
});
export const ZSyncOrganisationSubscriptionResponseSchema = z.void();
export type TSyncOrganisationSubscriptionRequest = z.infer<typeof ZSyncOrganisationSubscriptionRequestSchema>;
export type TSyncOrganisationSubscriptionResponse = z.infer<typeof ZSyncOrganisationSubscriptionResponseSchema>;