Merge branch 'main' into fix/cc-recipient-order-last

This commit is contained in:
Catalin Pit
2026-06-12 12:55:04 +03:00
committed by GitHub
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;
};
@@ -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>;
},
@@ -6,6 +6,8 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { SubscriptionStatus } from '@prisma/client';
import { Loader } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router';
import type Stripe from 'stripe';
import { match, P } from 'ts-pattern';
@@ -23,12 +25,51 @@ export default function TeamsSettingBillingPage() {
const organisation = useCurrentOrganisation();
const [searchParams, setSearchParams] = useSearchParams();
const utils = trpc.useUtils();
const { data: subscriptionQuery, isLoading: isLoadingSubscription } =
trpc.enterprise.billing.subscription.get.useQuery({
organisationId: organisation.id,
});
if (isLoadingSubscription || !subscriptionQuery) {
const { mutateAsync: syncSubscription, isPending: isSyncingSubscription } =
trpc.enterprise.billing.subscription.sync.useMutation();
const hasTriggeredCheckoutSyncRef = useRef(false);
const isCheckoutSuccess = searchParams.get('success') === 'true';
/**
* Eagerly sync the subscription from Stripe when returning from a successful
* checkout, since the webhook may not have arrived yet.
*/
useEffect(() => {
if (!isCheckoutSuccess || hasTriggeredCheckoutSyncRef.current) {
return;
}
hasTriggeredCheckoutSyncRef.current = true;
void syncSubscription({ organisationId: organisation.id })
.catch(() => {
// Non-fatal, webhooks will converge the subscription state shortly.
})
.finally(() => {
void utils.enterprise.billing.invalidate();
setSearchParams(
(params) => {
params.delete('success');
return params;
},
{ replace: true },
);
});
}, [isCheckoutSuccess, organisation.id]);
if (isLoadingSubscription || !subscriptionQuery || isSyncingSubscription) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
@@ -1,6 +1,7 @@
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT, PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
import { TrpcProvider } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
@@ -21,7 +22,11 @@ export default function Layout() {
return undefined;
}
if (organisation?.subscription && organisation.subscription.status === SubscriptionStatus.INACTIVE) {
const isRestricted =
(organisation.subscription && organisation.subscription.status === SubscriptionStatus.INACTIVE) ||
isOrganisationPendingPayment(organisation);
if (isRestricted) {
return {
quota: {
documents: 0,
@@ -42,7 +47,7 @@ export default function Layout() {
remaining: PAID_PLAN_LIMITS,
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
};
}, [organisation?.subscription]);
}, [organisation]);
if (!team) {
return (
+10
View File
@@ -1,5 +1,6 @@
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { DocumentSource, EnvelopeType, SubscriptionStatus } from '@prisma/client';
import { DateTime } from 'luxon';
@@ -69,6 +70,15 @@ export const getServerLimits = async ({ userId, teamId }: GetServerLimitsOptions
};
}
// Early return for organisations created ahead of a paid checkout that are still awaiting payment.
if (isOrganisationPendingPayment(organisation)) {
return {
quota: INACTIVE_PLAN_LIMITS,
remaining: INACTIVE_PLAN_LIMITS,
maximumEnvelopeItemCount,
};
}
// Allow unlimited documents for users with an unlimited documents claim.
// This also allows "free" claim users without subscriptions if they have this flag.
if (organisation.organisationClaim.flags.unlimitedDocuments) {
@@ -1,20 +1,13 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe';
import type Stripe from 'stripe';
export type CreateCheckoutSessionOptions = {
customerId: string;
priceId: string;
returnUrl: string;
subscriptionMetadata?: Stripe.Metadata;
};
export const createCheckoutSession = async ({
customerId,
priceId,
returnUrl,
subscriptionMetadata,
}: CreateCheckoutSessionOptions) => {
export const createCheckoutSession = async ({ customerId, priceId, returnUrl }: CreateCheckoutSessionOptions) => {
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
@@ -27,9 +20,6 @@ export const createCheckoutSession = async ({
success_url: `${returnUrl}?success=true`,
cancel_url: `${returnUrl}?canceled=true`,
billing_address_collection: 'required',
subscription_data: {
metadata: subscriptionMetadata,
},
});
if (!session.url) {
@@ -0,0 +1,283 @@
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
import { type Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { prisma } from '@documenso/prisma';
import { OrganisationType, type Prisma, SubscriptionStatus } from '@prisma/client';
import { match } from 'ts-pattern';
const LIVE_SUBSCRIPTION_STATUSES: Stripe.Subscription.Status[] = ['active', 'trialing', 'past_due'];
export type SyncStripeCustomerSubscriptionOptions = {
customerId: string;
/**
* When true, the organisationClaim will not be synced.
*
* Used by the admin sync route to update only the Subscription
* row while leaving claim entitlements untouched.
*/
bypassClaimUpdate?: boolean;
};
/**
* Idempotent, convergent sync of a Stripe customer's subscription state into the local database.
*
* Fetches the current truth from Stripe and writes it locally, regardless of which
* webhook event (or manual trigger) initiated the sync. Safe to run at any time,
* any number of times.
*
* This function never creates organisations.
*/
export const syncStripeCustomerSubscription = async ({
customerId,
bypassClaimUpdate = false,
}: SyncStripeCustomerSubscriptionOptions) => {
// Note: `data.items.data.price.product` would exceed Stripe's 4-level expansion
// limit on list endpoints, so the product is fetched separately when needed.
const stripeSubscriptions = await stripe.subscriptions.list({
customer: customerId,
status: 'all',
limit: 100,
});
const liveSubscriptions = stripeSubscriptions.data.filter((subscription) =>
LIVE_SUBSCRIPTION_STATUSES.includes(subscription.status),
);
if (liveSubscriptions.length > 1) {
console.error(`Customer ${customerId} has ${liveSubscriptions.length} live subscriptions, expected at most 1`);
throw new Error(`Customer ${customerId} has multiple live subscriptions`);
}
const organisation = await prisma.organisation.findFirst({
where: {
customerId,
},
include: {
organisationClaim: true,
subscription: true,
},
});
if (!organisation) {
console.error(`Organisation not found for customer ${customerId}, nothing to sync`);
return;
}
const liveSubscription = liveSubscriptions[0];
if (!liveSubscription) {
await handleNoLiveSubscription({ organisation });
return;
}
await handleLiveSubscription({
organisation,
subscription: liveSubscription,
customerId,
bypassClaimUpdate,
});
};
type OrganisationWithClaimAndSubscription = Prisma.OrganisationGetPayload<{
include: { organisationClaim: true; subscription: true };
}>;
type HandleNoLiveSubscriptionOptions = {
organisation: OrganisationWithClaimAndSubscription;
};
const handleNoLiveSubscription = async ({ organisation }: HandleNoLiveSubscriptionOptions) => {
// Individuals get their subscription deleted so they can return to the free plan.
if (organisation.organisationClaim.originalSubscriptionClaimId === INTERNAL_CLAIM_ID.INDIVIDUAL) {
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
await prisma.$transaction(async (tx) => {
if (organisation.subscription) {
await tx.subscription.delete({
where: {
id: organisation.subscription.id,
},
});
}
await tx.organisationClaim.update({
where: {
id: organisation.organisationClaim.id,
},
data: {
originalSubscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
...createOrganisationClaimUpsertData(freeSubscriptionClaim),
},
});
});
return;
}
// For all other cases, mark the subscription as inactive if a row exists.
if (organisation.subscription) {
await prisma.subscription.update({
where: {
id: organisation.subscription.id,
},
data: {
status: SubscriptionStatus.INACTIVE,
},
});
}
};
type HandleLiveSubscriptionOptions = {
organisation: OrganisationWithClaimAndSubscription;
subscription: Stripe.Subscription;
customerId: string;
bypassClaimUpdate: boolean;
};
const handleLiveSubscription = async ({
organisation,
subscription,
customerId,
bypassClaimUpdate,
}: HandleLiveSubscriptionOptions) => {
if (subscription.items.data.length !== 1) {
console.error(`No support for multiple subscription items on subscription ${subscription.id}`);
throw new Error(`No support for multiple subscription items on subscription ${subscription.id}`);
}
const subscriptionItem = subscription.items.data[0];
const claim = await extractStripeClaim(subscriptionItem.price);
if (!claim) {
console.error(`Subscription claim on ${subscriptionItem.price.id} not found`);
throw new Error(`Subscription claim on ${subscriptionItem.price.id} not found`);
}
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('trialing', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
.otherwise(() => SubscriptionStatus.INACTIVE);
const periodEnd =
subscription.status === 'trialing' && subscription.trial_end
? new Date(subscription.trial_end * 1000)
: new Date(subscription.current_period_end * 1000);
const shouldUpdateClaim =
!bypassClaimUpdate && organisation.organisationClaim.originalSubscriptionClaimId !== claim.id;
// Migrate the organisation type if it is no longer an individual/free plan.
// Never demote an ORGANISATION back to PERSONAL.
const shouldMigrateOrganisationType =
organisation.type === OrganisationType.PERSONAL &&
claim.id !== INTERNAL_CLAIM_ID.INDIVIDUAL &&
claim.id !== INTERNAL_CLAIM_ID.FREE;
await prisma.$transaction(async (tx) => {
await tx.subscription.upsert({
where: {
organisationId: organisation.id,
},
create: {
organisationId: organisation.id,
status,
customerId,
planId: subscription.id,
priceId: subscriptionItem.price.id,
periodEnd,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
update: {
status,
customerId,
planId: subscription.id,
priceId: subscriptionItem.price.id,
periodEnd,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
if (shouldUpdateClaim) {
await tx.organisationClaim.update({
where: {
id: organisation.organisationClaim.id,
},
data: {
originalSubscriptionClaimId: claim.id,
...createOrganisationClaimUpsertData(claim),
},
});
}
if (shouldMigrateOrganisationType) {
await tx.organisation.update({
where: {
id: organisation.id,
},
data: {
type: OrganisationType.ORGANISATION,
},
});
}
});
};
/**
* Checks the price metadata for a claimId, if it is missing it will fetch
* and check the product metadata for a claimId.
*
* The order of priority is:
* 1. Price metadata
* 2. Product metadata
*
* @returns The claimId or null if no claimId is found.
*/
export const extractStripeClaimId = async (priceId: Stripe.Price) => {
if (priceId.metadata.claimId) {
return priceId.metadata.claimId;
}
// Use the expanded product when available to avoid an extra API call.
if (typeof priceId.product !== 'string' && 'metadata' in priceId.product) {
return priceId.product.metadata.claimId || null;
}
const productId = typeof priceId.product === 'string' ? priceId.product : priceId.product.id;
const product = await stripe.products.retrieve(productId);
return product.metadata.claimId || null;
};
/**
* Checks the price metadata for a claimId, if it is missing it will fetch
* and check the product metadata for a claimId.
*
*/
export const extractStripeClaim = async (priceId: Stripe.Price) => {
const claimId = await extractStripeClaimId(priceId);
if (!claimId) {
return null;
}
const subscriptionClaim = await prisma.subscriptionClaim.findFirst({
where: { id: claimId },
});
if (!subscriptionClaim) {
console.error(`Subscription claim ${claimId} not found`);
return null;
}
return subscriptionClaim;
};
@@ -2,17 +2,29 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
import { env } from '@documenso/lib/utils/env';
import { match } from 'ts-pattern';
import { onSubscriptionCreated } from './on-subscription-created';
import { onSubscriptionDeleted } from './on-subscription-deleted';
import { onSubscriptionUpdated } from './on-subscription-updated';
import { syncStripeCustomerSubscription } from '../sync-stripe-customer-subscription';
type StripeWebhookResponse = {
success: boolean;
message: string;
};
/**
* Events that trigger a sync of the customer's subscription state.
*
* The event payload is never trusted beyond extracting the customer ID,
* the sync function fetches the current truth from Stripe.
*/
const SYNCED_EVENT_TYPES: string[] = [
'customer.subscription.created',
'customer.subscription.updated',
'customer.subscription.deleted',
'checkout.session.completed',
'invoice.payment_succeeded',
'invoice.payment_failed',
];
export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
try {
const isBillingEnabled = IS_BILLING_ENABLED();
@@ -60,69 +72,45 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
const event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
/**
* Notes:
* - Dropped invoice.payment_succeeded
* - Dropped invoice.payment_failed
* - Dropped checkout-session.completed
*/
return await match(event.type)
.with('customer.subscription.created', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const subscription = event.data.object as Stripe.Subscription;
if (!SYNCED_EVENT_TYPES.includes(event.type)) {
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
}
await onSubscriptionCreated({ subscription });
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const eventObject = event.data.object as { customer?: string | Stripe.Customer | null };
return Response.json({ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse, {
status: 200,
});
})
.with('customer.subscription.updated', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const subscription = event.data.object as Stripe.Subscription;
const customerId = typeof eventObject.customer === 'string' ? eventObject.customer : eventObject.customer?.id;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const previousAttributes = event.data.previous_attributes as Partial<Stripe.Subscription> | null;
if (!customerId) {
console.error(`No customer found on ${event.type} event ${event.id}, nothing to sync`);
await onSubscriptionUpdated({ subscription, previousAttributes });
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
}
return Response.json({ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse, {
status: 200,
});
})
.with('customer.subscription.deleted', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const subscription = event.data.object as Stripe.Subscription;
await syncStripeCustomerSubscription({ customerId });
await onSubscriptionDeleted({ subscription });
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
})
.otherwise(() => {
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
});
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
} catch (err) {
console.error(err);
if (err instanceof Response) {
const message = await err.json();
console.error(message);
return err;
}
return Response.json(
{
success: false,
@@ -1,214 +0,0 @@
import {
createOrganisation,
createOrganisationClaimUpsertData,
} from '@documenso/lib/server-only/organisation/create-organisation';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import type { StripeOrganisationCreateMetadata } from '@documenso/lib/types/subscription';
import { INTERNAL_CLAIM_ID, ZStripeOrganisationCreateMetadataSchema } from '@documenso/lib/types/subscription';
import { prisma } from '@documenso/prisma';
import { OrganisationType, type SubscriptionClaim, SubscriptionStatus } from '@prisma/client';
import { match } from 'ts-pattern';
import { extractStripeClaim } from './on-subscription-updated';
export type OnSubscriptionCreatedOptions = {
subscription: Stripe.Subscription;
};
type StripeWebhookResponse = {
success: boolean;
message: string;
};
/**
* Todo: We might want to pull this into a job so we can do steps. Since if organisation creation passes but
* fails after this would be automatically rerun by Stripe, which means duplicate organisations can be
* potentially created.
*/
export const onSubscriptionCreated = async ({ subscription }: OnSubscriptionCreatedOptions) => {
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
// Todo: logging
if (subscription.items.data.length !== 1) {
console.error('No support for multiple items');
throw Response.json(
{
success: false,
message: 'No support for multiple items',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const subscriptionItem = subscription.items.data[0];
const claim = await extractStripeClaim(subscriptionItem.price);
// Todo: logging
if (!claim) {
console.error(`Subscription claim on ${subscriptionItem.price.id} not found`);
throw Response.json(
{
success: false,
message: `Subscription claim on ${subscriptionItem.price.id} not found`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const organisationCreateData = subscription.metadata?.organisationCreateData;
// A new subscription can be for an existing organisation or a new one.
const organisationId = organisationCreateData
? await handleOrganisationCreate({
customerId,
claim,
unknownCreateData: organisationCreateData,
})
: await handleOrganisationUpdate({
customerId,
claim,
});
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('trialing', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
.otherwise(() => SubscriptionStatus.INACTIVE);
const periodEnd =
subscription.status === 'trialing' && subscription.trial_end
? new Date(subscription.trial_end * 1000)
: new Date(subscription.current_period_end * 1000);
await prisma.subscription.upsert({
where: {
organisationId,
},
create: {
organisationId,
status,
customerId,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
update: {
status,
customerId,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
};
type HandleOrganisationCreateOptions = {
customerId: string;
claim: Omit<SubscriptionClaim, 'createdAt' | 'updatedAt'>;
unknownCreateData: string;
};
/**
* Handles the creation of an organisation.
*/
const handleOrganisationCreate = async ({ customerId, claim, unknownCreateData }: HandleOrganisationCreateOptions) => {
let organisationCreateFlowData: StripeOrganisationCreateMetadata | null = null;
const parseResult = ZStripeOrganisationCreateMetadataSchema.safeParse(JSON.parse(unknownCreateData));
if (!parseResult.success) {
console.error('Invalid organisation create flow data');
throw Response.json(
{
success: false,
message: 'Invalid organisation create flow data',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
organisationCreateFlowData = parseResult.data;
const createdOrganisation = await createOrganisation({
name: organisationCreateFlowData.organisationName,
userId: organisationCreateFlowData.userId,
type: OrganisationType.ORGANISATION,
customerId,
claim,
});
return createdOrganisation.id;
};
type HandleOrganisationUpdateOptions = {
customerId: string;
claim: Omit<SubscriptionClaim, 'createdAt' | 'updatedAt'>;
};
/**
* Handles the updating an exist organisation claims.
*/
const handleOrganisationUpdate = async ({ customerId, claim }: HandleOrganisationUpdateOptions) => {
const organisation = await prisma.organisation.findFirst({
where: {
customerId,
},
include: {
subscription: true,
organisationClaim: true,
},
});
if (!organisation) {
throw Response.json(
{
success: false,
message: `Organisation not found`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
// Todo: logging
if (organisation.subscription && organisation.subscription.status !== SubscriptionStatus.INACTIVE) {
console.error('Organisation already has an active subscription');
// This should never happen
throw Response.json(
{
success: false,
message: `Organisation already has an active subscription`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
let newOrganisationType: OrganisationType = OrganisationType.ORGANISATION;
// Keep the organisation as personal if the claim is for an individual.
if (organisation.type === OrganisationType.PERSONAL && claim.id === INTERNAL_CLAIM_ID.INDIVIDUAL) {
newOrganisationType = OrganisationType.PERSONAL;
}
await prisma.organisation.update({
where: {
id: organisation.id,
},
data: {
type: newOrganisationType,
organisationClaim: {
update: {
originalSubscriptionClaimId: claim.id,
...createOrganisationClaimUpsertData(claim),
},
},
},
});
return organisation.id;
};
@@ -1,90 +0,0 @@
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@prisma/client';
import { extractStripeClaimId } from './on-subscription-updated';
export type OnSubscriptionDeletedOptions = {
subscription: Stripe.Subscription;
};
export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDeletedOptions) => {
const existingSubscription = await prisma.subscription.findUnique({
where: {
planId: subscription.id,
},
include: {
organisation: {
include: {
organisationClaim: true,
},
},
},
});
// If the subscription doesn't exist, we don't need to do anything.
if (!existingSubscription) {
return;
}
const subscriptionClaimId = await extractClaimIdFromStripeSubscription(subscription);
// Individuals get their subscription deleted so they can return to the
// free plan.
if (subscriptionClaimId === INTERNAL_CLAIM_ID.INDIVIDUAL) {
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
await prisma.$transaction(async (tx) => {
await tx.subscription.delete({
where: {
id: existingSubscription.id,
},
});
await tx.organisationClaim.update({
where: {
id: existingSubscription.organisation.organisationClaim.id,
},
data: {
originalSubscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
...createOrganisationClaimUpsertData(freeSubscriptionClaim),
},
});
});
return;
}
// For all other cases, mark the subscription as inactive since
// they should still have a "Personal" account.
await prisma.subscription.update({
where: {
id: existingSubscription.id,
},
data: {
status: SubscriptionStatus.INACTIVE,
},
});
};
/**
* Extracts the claim ID from the Stripe subscription.
*
* Returns `null` if no claim ID found.
*/
const extractClaimIdFromStripeSubscription = async (subscription: Stripe.Subscription) => {
const deletedItem = subscription.items.data[0];
if (!deletedItem) {
return null;
}
try {
return await extractStripeClaimId(deletedItem.price);
} catch (error) {
console.error(error);
return null;
}
};
@@ -1,194 +0,0 @@
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
import { type Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { prisma } from '@documenso/prisma';
import { OrganisationType, SubscriptionStatus } from '@prisma/client';
import { match } from 'ts-pattern';
export type OnSubscriptionUpdatedOptions = {
subscription: Stripe.Subscription;
previousAttributes: Partial<Stripe.Subscription> | null;
/**
* When true, the organisationClaim will not be synced.
*
* Used by the admin sync route to update only the Subscription
* row while leaving claim entitlements untouched.
*/
bypassClaimUpdate?: boolean;
};
type StripeWebhookResponse = {
success: boolean;
message: string;
};
export const onSubscriptionUpdated = async ({
subscription,
previousAttributes,
bypassClaimUpdate = false,
}: OnSubscriptionUpdatedOptions) => {
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
// Todo: logging
if (subscription.items.data.length !== 1) {
console.error('No support for multiple items');
throw Response.json(
{
success: false,
message: 'No support for multiple items',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const organisation = await prisma.organisation.findFirst({
where: {
customerId,
},
include: {
organisationClaim: true,
subscription: true,
},
});
if (!organisation) {
throw Response.json(
{
success: false,
message: `Organisation not found`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
if (
organisation.subscription &&
organisation.subscription.status !== SubscriptionStatus.INACTIVE &&
organisation.subscription.planId !== subscription.id
) {
console.error('[WARNING]: Organisation might have two subscriptions');
}
const previousItem = previousAttributes?.items?.data[0];
const updatedItem = subscription.items.data[0];
const previousSubscriptionClaimId = previousItem ? await extractStripeClaimId(previousItem.price) : null;
const updatedSubscriptionClaim = await extractStripeClaim(updatedItem.price);
if (!updatedSubscriptionClaim) {
console.error(`Subscription claim on ${updatedItem.price.id} not found`);
throw Response.json(
{
success: false,
message: `Subscription claim on ${updatedItem.price.id} not found`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const newClaimFound = previousSubscriptionClaimId !== updatedSubscriptionClaim.id;
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('trialing', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
.otherwise(() => SubscriptionStatus.INACTIVE);
const periodEnd =
subscription.status === 'trialing' && subscription.trial_end
? new Date(subscription.trial_end * 1000)
: new Date(subscription.current_period_end * 1000);
// Migrate the organisation type if it is no longer an individual plan.
if (
updatedSubscriptionClaim.id !== INTERNAL_CLAIM_ID.INDIVIDUAL &&
updatedSubscriptionClaim.id !== INTERNAL_CLAIM_ID.FREE &&
organisation.type === OrganisationType.PERSONAL
) {
await prisma.organisation.update({
where: {
id: organisation.id,
},
data: {
type: OrganisationType.ORGANISATION,
},
});
}
await prisma.$transaction(async (tx) => {
await tx.subscription.update({
where: {
organisationId: organisation.id,
},
data: {
status: status,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
// Override current organisation claim if new one is found.
// Skipped when bypassClaimUpdate is set.
if (!bypassClaimUpdate && newClaimFound) {
await tx.organisationClaim.update({
where: {
id: organisation.organisationClaim.id,
},
data: {
originalSubscriptionClaimId: updatedSubscriptionClaim.id,
...createOrganisationClaimUpsertData(updatedSubscriptionClaim),
},
});
}
});
};
/**
* Checks the price metadata for a claimId, if it is missing it will fetch
* and check the product metadata for a claimId.
*
* The order of priority is:
* 1. Price metadata
* 2. Product metadata
*
* @returns The claimId or null if no claimId is found.
*/
export const extractStripeClaimId = async (priceId: Stripe.Price) => {
if (priceId.metadata.claimId) {
return priceId.metadata.claimId;
}
const productId = typeof priceId.product === 'string' ? priceId.product : priceId.product.id;
const product = await stripe.products.retrieve(productId);
return product.metadata.claimId || null;
};
/**
* Checks the price metadata for a claimId, if it is missing it will fetch
* and check the product metadata for a claimId.
*
*/
export const extractStripeClaim = async (priceId: Stripe.Price) => {
const claimId = await extractStripeClaimId(priceId);
if (!claimId) {
return null;
}
const subscriptionClaim = await prisma.subscriptionClaim.findFirst({
where: { id: claimId },
});
if (!subscriptionClaim) {
console.error(`Subscription claim ${claimId} not found`);
return null;
}
return subscriptionClaim;
};
@@ -72,6 +72,14 @@ export const reportSenderRateLimit = createRateLimit({
window: '7d',
});
// ---- Billing ----
export const syncSubscriptionRateLimit = createRateLimit({
action: 'billing.sync-subscription',
max: 10,
window: '15m',
});
// ---- API (Tier 4 - Standard) ----
export const apiV1RateLimit = createRateLimit({
-8
View File
@@ -1,4 +1,3 @@
import { ZOrganisationNameSchema } from '@documenso/trpc/server/organisation-router/create-organisation.types';
import type { SubscriptionClaim } from '@prisma/client';
import { z } from 'zod';
@@ -179,10 +178,3 @@ export const internalClaims: InternalClaims = {
name: 'Early Adopter',
},
} as const;
export const ZStripeOrganisationCreateMetadataSchema = z.object({
organisationName: ZOrganisationNameSchema,
userId: z.number(),
});
export type StripeOrganisationCreateMetadata = z.infer<typeof ZStripeOrganisationCreateMetadataSchema>;
+35 -12
View File
@@ -1,19 +1,9 @@
import type { Subscription } from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
import { OrganisationType } from '@prisma/client';
import { IS_BILLING_ENABLED } from '../constants/app';
import { AppError, AppErrorCode } from '../errors/app-error';
import type { StripeOrganisationCreateMetadata } from '../types/subscription';
export const generateStripeOrganisationCreateMetadata = (organisationName: string, userId: number) => {
const metadata: StripeOrganisationCreateMetadata = {
organisationName,
userId,
};
return {
organisationCreateData: JSON.stringify(metadata),
};
};
import { INTERNAL_CLAIM_ID } from '../types/subscription';
/**
* Throws an error if billing is enabled and no subscription is found.
@@ -33,3 +23,36 @@ export const validateIfSubscriptionIsRequired = (subscription?: Subscription | n
return subscription;
};
type PendingPaymentOrganisation = {
type: OrganisationType;
subscription?: unknown;
organisationClaim: {
originalSubscriptionClaimId: string | null;
};
};
/**
* Whether the organisation was created ahead of a paid checkout and is still awaiting
* its first successful payment.
*
* Such organisations have no subscription row and still carry the copied "free" claim,
* and must be treated as restricted until the Stripe webhook sync activates them.
*
* Always returns false when billing is disabled (self-hosted).
*/
export const isOrganisationPendingPayment = (organisation: PendingPaymentOrganisation) => {
if (!IS_BILLING_ENABLED()) {
return false;
}
if (organisation.type !== OrganisationType.ORGANISATION) {
return false;
}
if (organisation.subscription) {
return false;
}
return organisation.organisationClaim.originalSubscriptionClaimId === INTERNAL_CLAIM_ID.FREE;
};
@@ -1,6 +1,5 @@
import { onSubscriptionUpdated } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
import { syncStripeCustomerSubscription } from '@documenso/ee/server-only/stripe/sync-stripe-customer-subscription';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
@@ -24,9 +23,6 @@ export const syncOrganisationSubscriptionRoute = adminProcedure
const organisation = await prisma.organisation.findUnique({
where: { id: organisationId },
include: {
subscription: true,
},
});
if (!organisation) {
@@ -35,47 +31,14 @@ export const syncOrganisationSubscriptionRoute = adminProcedure
});
}
if (!organisation.subscription) {
if (!organisation.customerId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Organisation has no subscription to sync',
message: 'Organisation has no Stripe customer to sync from',
});
}
let stripeSubscription: Stripe.Subscription;
try {
stripeSubscription = await stripe.subscriptions.retrieve(organisation.subscription.planId, {
expand: ['items.data.price.product'],
});
} catch (error) {
if (error instanceof Stripe.errors.StripeInvalidRequestError && error.code === 'resource_missing') {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Subscription not found on Stripe',
});
}
throw error;
}
const stripeCustomerId =
typeof stripeSubscription.customer === 'string' ? stripeSubscription.customer : stripeSubscription.customer.id;
if (organisation.customerId !== stripeCustomerId) {
ctx.logger.error({
message: 'Organisation customerId does not match Stripe subscription customer',
organisationId,
localCustomerId: organisation.customerId,
stripeCustomerId,
});
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Organisation customerId mismatch: local=${organisation.customerId ?? 'null'}, Stripe=${stripeCustomerId}`,
});
}
await onSubscriptionUpdated({
subscription: stripeSubscription,
previousAttributes: null,
await syncStripeCustomerSubscription({
customerId: organisation.customerId,
bypassClaimUpdate: !syncClaims,
});
});
@@ -14,6 +14,7 @@ import { getPlansRoute } from './get-plans';
import { getSubscriptionRoute } from './get-subscription';
import { linkOrganisationAccountRoute } from './link-organisation-account';
import { manageSubscriptionRoute } from './manage-subscription';
import { syncSubscriptionRoute } from './sync-subscription';
import { updateOrganisationAuthenticationPortalRoute } from './update-organisation-authentication-portal';
import { updateOrganisationEmailRoute } from './update-organisation-email';
import { verifyOrganisationEmailDomainRoute } from './verify-organisation-email-domain';
@@ -48,6 +49,7 @@ export const enterpriseRouter = router({
get: getSubscriptionRoute,
create: createSubscriptionRoute,
manage: manageSubscriptionRoute,
sync: syncSubscriptionRoute,
},
invoices: {
get: getInvoicesRoute,
@@ -0,0 +1,70 @@
import { syncStripeCustomerSubscription } from '@documenso/ee/server-only/stripe/sync-stripe-customer-subscription';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { assertRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import { syncSubscriptionRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import { ZSyncSubscriptionRequestSchema, ZSyncSubscriptionResponseSchema } from './sync-subscription.types';
export const syncSubscriptionRoute = authenticatedProcedure
.input(ZSyncSubscriptionRequestSchema)
.output(ZSyncSubscriptionResponseSchema)
.mutation(async ({ ctx, input }) => {
const { organisationId } = input;
ctx.logger.info({
input: {
organisationId,
},
});
const userId = ctx.user.id;
if (!IS_BILLING_ENABLED()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Billing is not enabled',
});
}
const rateLimitResult = await syncSubscriptionRateLimit.check({
ip: ctx.metadata.requestMetadata.ipAddress ?? 'unknown',
identifier: `${userId}`,
});
assertRateLimit(rateLimitResult);
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP.MANAGE_BILLING,
}),
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
if (!organisation.customerId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Organisation has no billing customer',
});
}
await syncStripeCustomerSubscription({
customerId: organisation.customerId,
}).catch((error) => {
ctx.logger.error({
msg: 'Failed to sync the subscription from Stripe',
error,
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to sync the subscription from Stripe',
});
});
});
@@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZSyncSubscriptionRequestSchema = z.object({
organisationId: z.string().describe('The organisation to sync the subscription for'),
});
export const ZSyncSubscriptionResponseSchema = z.void();
export type TSyncSubscriptionRequest = z.infer<typeof ZSyncSubscriptionRequestSchema>;
export type TSyncSubscriptionResponse = z.infer<typeof ZSyncSubscriptionResponseSchema>;
@@ -5,9 +5,8 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createOrganisation } from '@documenso/lib/server-only/organisation/create-organisation';
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { generateStripeOrganisationCreateMetadata } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { OrganisationType } from '@prisma/client';
import { OrganisationType, SubscriptionStatus } from '@prisma/client';
import { authenticatedProcedure } from '../trpc';
import { ZCreateOrganisationRequestSchema, ZCreateOrganisationResponseSchema } from './create-organisation.types';
@@ -43,18 +42,67 @@ export const createOrganisationRoute = authenticatedProcedure
}
}
// Create checkout session for payment.
// Create the organisation upfront, then redirect to checkout for payment.
// The webhook sync will attach the real subscription and claim after payment.
if (IS_BILLING_ENABLED() && priceId) {
const customer = await createCustomer({
email: user.email,
name: user.name || user.email,
const pendingOrganisation = await prisma.organisation.findFirst({
where: {
ownerUserId: user.id,
type: OrganisationType.ORGANISATION,
OR: [
{
subscription: {
is: null,
},
},
{
subscription: {
status: SubscriptionStatus.INACTIVE,
},
},
],
},
});
if (pendingOrganisation) {
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
message: 'You have a pending organisation awaiting payment. Complete or remove it before creating a new one.',
});
}
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
const organisation = await createOrganisation({
userId: user.id,
name,
type: OrganisationType.ORGANISATION,
claim: freeSubscriptionClaim,
});
let customerId = organisation.customerId;
if (!customerId) {
const customer = await createCustomer({
email: user.email,
name: user.name || user.email,
});
customerId = customer.id;
await prisma.organisation.update({
where: {
id: organisation.id,
},
data: {
customerId,
},
});
}
const checkoutUrl = await createCheckoutSession({
priceId,
customerId: customer.id,
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/organisations`,
subscriptionMetadata: generateStripeOrganisationCreateMetadata(name, user.id),
customerId,
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/o/${organisation.url}/settings/billing`,
});
return {