mirror of
https://github.com/documenso/documenso.git
synced 2026-08-24 15:22:26 +10:00
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
221 lines
7.6 KiB
TypeScript
221 lines
7.6 KiB
TypeScript
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';
|
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
|
import { Button } from '@documenso/ui/primitives/button';
|
|
import {
|
|
Dialog,
|
|
DialogClose,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@documenso/ui/primitives/dialog';
|
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
import { msg } from '@lingui/core/macro';
|
|
import { useLingui } from '@lingui/react';
|
|
import { Trans } from '@lingui/react/macro';
|
|
import { SubscriptionStatus } from '@prisma/client';
|
|
import { AlertTriangle } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
import { Link } from 'react-router';
|
|
import { match } from 'ts-pattern';
|
|
|
|
export const OrganisationBillingBanner = () => {
|
|
const { _ } = useLingui();
|
|
const { toast } = useToast();
|
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
const organisation = useOptionalCurrentOrganisation();
|
|
|
|
const { mutateAsync: manageSubscription, isPending } = trpc.enterprise.billing.subscription.manage.useMutation();
|
|
|
|
const handleCreatePortal = async (organisationId: string) => {
|
|
try {
|
|
const { redirectUrl } = await manageSubscription({ organisationId });
|
|
|
|
window.open(redirectUrl, '_blank');
|
|
|
|
setIsOpen(false);
|
|
} catch (err) {
|
|
toast({
|
|
title: _(msg`Something went wrong`),
|
|
description: _(
|
|
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
|
|
),
|
|
variant: 'destructive',
|
|
duration: 10000,
|
|
});
|
|
}
|
|
};
|
|
|
|
const bannerVariant = getBannerVariant(organisation);
|
|
|
|
if (!organisation || bannerVariant === null) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={cn({
|
|
'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(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': bannerVariant === 'PAST_DUE',
|
|
'text-destructive-foreground hover:bg-destructive hover:text-white':
|
|
bannerVariant === 'INACTIVE' || bannerVariant === 'PENDING_PAYMENT',
|
|
})}
|
|
disabled={isPending}
|
|
onClick={() => setIsOpen(true)}
|
|
size="sm"
|
|
>
|
|
<Trans>Resolve</Trans>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<Dialog open={isOpen} onOpenChange={(value) => !isPending && setIsOpen(value)}>
|
|
{match(bannerVariant)
|
|
.with('PAST_DUE', () => (
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
<Trans>Payment overdue</Trans>
|
|
</DialogTitle>
|
|
|
|
<DialogDescription>
|
|
<Trans>Your payment is overdue. Please settle the payment to avoid any service disruptions.</Trans>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
|
|
<DialogFooter>
|
|
<Button loading={isPending} onClick={async () => handleCreatePortal(organisation.id)}>
|
|
<Trans>Resolve payment</Trans>
|
|
</Button>
|
|
</DialogFooter>
|
|
)}
|
|
</DialogContent>
|
|
))
|
|
.with('INACTIVE', () => (
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
<Trans>Subscription invalid</Trans>
|
|
</DialogTitle>
|
|
|
|
<DialogDescription>
|
|
<Trans>
|
|
Your plan is no longer valid. Please subscribe to a new plan to continue using Documenso.
|
|
</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>
|
|
))
|
|
.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;
|
|
};
|