mirror of
https://github.com/documenso/documenso.git
synced 2026-08-25 15:52:29 +10:00
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:
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user