Files
documenso/apps/remix/app/components/tables/user-billing-organisations-table.tsx
T
Lucas Smith 3887aa67c8 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
2026-06-12 16:01:03 +10:00

114 lines
4.1 KiB
TypeScript

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';
import { Button } from '@documenso/ui/primitives/button';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { Trans, useLingui } from '@lingui/react/macro';
import { SubscriptionStatus } from '@prisma/client';
import { useMemo } from 'react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
export const UserBillingOrganisationsTable = () => {
const { t } = useLingui();
const { organisations } = useSession();
const billingOrganisations = useMemo(() => {
return organisations.filter((org) => canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole));
}, [organisations]);
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,
}))
.with(SubscriptionStatus.PAST_DUE, () => ({
label: t({ message: `Past Due`, context: `Subscription status` }),
variant: 'warning' as const,
}))
.with(SubscriptionStatus.INACTIVE, () => ({
label: t({ message: `Inactive`, 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(() => {
return [
{
header: t`Organisation`,
accessorKey: 'name',
cell: ({ row }) => (
<Link to={`/o/${row.original.url}`} preventScrollReset={true}>
<AvatarWithText
avatarSrc={formatAvatarUrl(row.original.avatarImageId)}
avatarClass="h-12 w-12"
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
primaryText={<span className="font-semibold text-foreground/80">{row.original.name}</span>}
secondaryText={`${NEXT_PUBLIC_WEBAPP_URL()}/o/${row.original.url}`}
/>
</Link>
),
},
{
header: t`Subscription Status`,
accessorKey: 'subscription',
cell: ({ row }) => {
const { label, variant } = getSubscriptionStatusDisplay(row.original);
return <Badge variant={variant}>{label}</Badge>;
},
},
{
header: t`Actions`,
id: 'actions',
cell: ({ row }) => (
<Button asChild variant="outline">
<Link to={`/o/${row.original.url}/settings/billing`}>
<Trans>Manage Billing</Trans>
</Link>
</Button>
),
},
] satisfies DataTableColumnDef<(typeof billingOrganisations)[number]>[];
}, [billingOrganisations]);
if (billingOrganisations.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12 text-center text-muted-foreground">
<p className="text-sm">
<Trans>You don't manage billing for any organisations.</Trans>
</p>
</div>
);
}
return (
<DataTable
columns={columns}
data={billingOrganisations}
perPage={billingOrganisations.length}
currentPage={1}
totalPages={1}
/>
);
};