fix: rework stripe webhooks into idempotent subscription sync (#2977)

Replace per-event webhook handlers with a single sync function that
fetches the current state from Stripe and converges the local
subscription, claim, and organisation type.

- Create organisations upfront before checkout, restricted as
  "pending payment" until the first payment syncs
- Add rate-limited subscription sync route, triggered on checkout
  success so the UI doesn't wait on webhooks
- Surface pending payment state in banner, billing table, and limits
This commit is contained in:
Lucas Smith
2026-06-12 16:01:03 +10:00
committed by GitHub
parent b84b87cea6
commit 3887aa67c8
19 changed files with 667 additions and 664 deletions
@@ -1,5 +1,6 @@
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -53,9 +54,9 @@ export const OrganisationBillingBanner = () => {
}
};
const subscriptionStatus = organisation?.subscription?.status;
const bannerVariant = getBannerVariant(organisation);
if (!organisation || subscriptionStatus === undefined || subscriptionStatus === SubscriptionStatus.ACTIVE) {
if (!organisation || bannerVariant === null) {
return null;
}
@@ -63,27 +64,28 @@ export const OrganisationBillingBanner = () => {
<>
<div
className={cn({
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400': subscriptionStatus === SubscriptionStatus.PAST_DUE,
'bg-destructive text-destructive-foreground': subscriptionStatus === SubscriptionStatus.INACTIVE,
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400': bannerVariant === 'PAST_DUE',
'bg-destructive text-destructive-foreground':
bannerVariant === 'INACTIVE' || bannerVariant === 'PENDING_PAYMENT',
})}
>
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 font-medium text-sm">
<div className="flex items-center">
<AlertTriangle className="mr-2.5 h-5 w-5" />
{match(subscriptionStatus)
.with(SubscriptionStatus.PAST_DUE, () => <Trans>Payment overdue</Trans>)
.with(SubscriptionStatus.INACTIVE, () => <Trans>Restricted Access</Trans>)
{match(bannerVariant)
.with('PAST_DUE', () => <Trans>Payment overdue</Trans>)
.with('INACTIVE', () => <Trans>Restricted Access</Trans>)
.with('PENDING_PAYMENT', () => <Trans>Payment required</Trans>)
.exhaustive()}
</div>
<Button
variant="outline"
className={cn({
'text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500':
subscriptionStatus === SubscriptionStatus.PAST_DUE,
'text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500': bannerVariant === 'PAST_DUE',
'text-destructive-foreground hover:bg-destructive hover:text-white':
subscriptionStatus === SubscriptionStatus.INACTIVE,
bannerVariant === 'INACTIVE' || bannerVariant === 'PENDING_PAYMENT',
})}
disabled={isPending}
onClick={() => setIsOpen(true)}
@@ -95,8 +97,8 @@ export const OrganisationBillingBanner = () => {
</div>
<Dialog open={isOpen} onOpenChange={(value) => !isPending && setIsOpen(value)}>
{match(subscriptionStatus)
.with(SubscriptionStatus.PAST_DUE, () => (
{match(bannerVariant)
.with('PAST_DUE', () => (
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -117,7 +119,7 @@ export const OrganisationBillingBanner = () => {
)}
</DialogContent>
))
.with(SubscriptionStatus.INACTIVE, () => (
.with('INACTIVE', () => (
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -153,8 +155,66 @@ export const OrganisationBillingBanner = () => {
)}
</DialogContent>
))
.otherwise(() => null)}
.with('PENDING_PAYMENT', () => (
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Payment required</Trans>
</DialogTitle>
<DialogDescription>
<Trans>This organisation is awaiting payment. Complete checkout to unlock it.</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="neutral">
<AlertDescription>
<Trans>
If there is any issue with your subscription, please contact us at{' '}
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>.
</Trans>
</AlertDescription>
</Alert>
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
<DialogFooter>
<DialogClose asChild>
<Button asChild>
<Link to={`/o/${organisation.url}/settings/billing`}>
<Trans>Manage Billing</Trans>
</Link>
</Button>
</DialogClose>
</DialogFooter>
)}
</DialogContent>
))
.exhaustive()}
</Dialog>
</>
);
};
type BannerVariant = 'PAST_DUE' | 'INACTIVE' | 'PENDING_PAYMENT';
const getBannerVariant = (organisation: ReturnType<typeof useOptionalCurrentOrganisation>): BannerVariant | null => {
if (!organisation) {
return null;
}
if (isOrganisationPendingPayment(organisation)) {
return 'PENDING_PAYMENT';
}
const subscriptionStatus = organisation.subscription?.status;
if (subscriptionStatus === SubscriptionStatus.PAST_DUE) {
return 'PAST_DUE';
}
if (subscriptionStatus === SubscriptionStatus.INACTIVE) {
return 'INACTIVE';
}
return null;
};