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,6 +1,7 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
@@ -21,8 +22,8 @@ export const UserBillingOrganisationsTable = () => {
return organisations.filter((org) => canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole));
}, [organisations]);
const getSubscriptionStatusDisplay = (status: SubscriptionStatus | undefined) => {
return match(status)
const getSubscriptionStatusDisplay = (organisation: (typeof billingOrganisations)[number]) => {
return match(organisation.subscription?.status)
.with(SubscriptionStatus.ACTIVE, () => ({
label: t({ message: `Active`, context: `Subscription status` }),
variant: 'default' as const,
@@ -35,10 +36,19 @@ export const UserBillingOrganisationsTable = () => {
label: t({ message: `Inactive`, context: `Subscription status` }),
variant: 'neutral' as const,
}))
.otherwise(() => ({
label: t({ message: `Free`, context: `Subscription status` }),
variant: 'neutral' as const,
}));
.otherwise(() => {
if (isOrganisationPendingPayment(organisation)) {
return {
label: t({ message: `Free (Pending)`, context: `Subscription status` }),
variant: 'warning' as const,
};
}
return {
label: t({ message: `Free`, context: `Subscription status` }),
variant: 'neutral' as const,
};
});
};
const columns = useMemo(() => {
@@ -62,9 +72,7 @@ export const UserBillingOrganisationsTable = () => {
header: t`Subscription Status`,
accessorKey: 'subscription',
cell: ({ row }) => {
const subscription = row.original.subscription;
const status = subscription?.status;
const { label, variant } = getSubscriptionStatusDisplay(status);
const { label, variant } = getSubscriptionStatusDisplay(row.original);
return <Badge variant={variant}>{label}</Badge>;
},