>
);
};
+
+type BannerVariant = 'PAST_DUE' | 'INACTIVE' | 'PENDING_PAYMENT';
+
+const getBannerVariant = (organisation: ReturnType
): 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;
+};
diff --git a/apps/remix/app/components/tables/user-billing-organisations-table.tsx b/apps/remix/app/components/tables/user-billing-organisations-table.tsx
index 9cf3aebfc..468909b9a 100644
--- a/apps/remix/app/components/tables/user-billing-organisations-table.tsx
+++ b/apps/remix/app/components/tables/user-billing-organisations-table.tsx
@@ -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 {label};
},
diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.billing.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.billing.tsx
index 652e35255..650bcd3e8 100644
--- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.billing.tsx
+++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.billing.tsx
@@ -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 (
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx
index 1e6a92110..6d40357b2 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx
@@ -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 (
diff --git a/packages/ee/server-only/limits/server.ts b/packages/ee/server-only/limits/server.ts
index 3164ee51f..f13da2cd6 100644
--- a/packages/ee/server-only/limits/server.ts
+++ b/packages/ee/server-only/limits/server.ts
@@ -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) {
diff --git a/packages/ee/server-only/stripe/create-checkout-session.ts b/packages/ee/server-only/stripe/create-checkout-session.ts
index 04c846055..4f04f3543 100644
--- a/packages/ee/server-only/stripe/create-checkout-session.ts
+++ b/packages/ee/server-only/stripe/create-checkout-session.ts
@@ -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) {
diff --git a/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts b/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts
new file mode 100644
index 000000000..29a4d7b7f
--- /dev/null
+++ b/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts
@@ -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;
+};
diff --git a/packages/ee/server-only/stripe/webhook/handler.ts b/packages/ee/server-only/stripe/webhook/handler.ts
index b8b321e86..03f07daf3 100644
--- a/packages/ee/server-only/stripe/webhook/handler.ts
+++ b/packages/ee/server-only/stripe/webhook/handler.ts
@@ -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 => {
try {
const isBillingEnabled = IS_BILLING_ENABLED();
@@ -60,69 +72,45 @@ export const stripeWebhookHandler = async (req: Request): Promise => {
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 | 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,
diff --git a/packages/ee/server-only/stripe/webhook/on-subscription-created.ts b/packages/ee/server-only/stripe/webhook/on-subscription-created.ts
deleted file mode 100644
index 655bdbbf8..000000000
--- a/packages/ee/server-only/stripe/webhook/on-subscription-created.ts
+++ /dev/null
@@ -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;
- 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;
-};
-
-/**
- * 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;
-};
diff --git a/packages/ee/server-only/stripe/webhook/on-subscription-deleted.ts b/packages/ee/server-only/stripe/webhook/on-subscription-deleted.ts
deleted file mode 100644
index 4bb78fe7b..000000000
--- a/packages/ee/server-only/stripe/webhook/on-subscription-deleted.ts
+++ /dev/null
@@ -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;
- }
-};
diff --git a/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts b/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts
deleted file mode 100644
index ad8692b03..000000000
--- a/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts
+++ /dev/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 | 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;
-};
diff --git a/packages/lib/server-only/rate-limit/rate-limits.ts b/packages/lib/server-only/rate-limit/rate-limits.ts
index a42c0f1a6..46233354c 100644
--- a/packages/lib/server-only/rate-limit/rate-limits.ts
+++ b/packages/lib/server-only/rate-limit/rate-limits.ts
@@ -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({
diff --git a/packages/lib/types/subscription.ts b/packages/lib/types/subscription.ts
index 5cd3878ee..9ce0ea5ef 100644
--- a/packages/lib/types/subscription.ts
+++ b/packages/lib/types/subscription.ts
@@ -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;
diff --git a/packages/lib/utils/billing.ts b/packages/lib/utils/billing.ts
index be3fda96b..426ff43f3 100644
--- a/packages/lib/utils/billing.ts
+++ b/packages/lib/utils/billing.ts
@@ -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;
+};
diff --git a/packages/trpc/server/admin-router/sync-organisation-subscription.ts b/packages/trpc/server/admin-router/sync-organisation-subscription.ts
index ae9f264b2..aa709206a 100644
--- a/packages/trpc/server/admin-router/sync-organisation-subscription.ts
+++ b/packages/trpc/server/admin-router/sync-organisation-subscription.ts
@@ -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,
});
});
diff --git a/packages/trpc/server/enterprise-router/router.ts b/packages/trpc/server/enterprise-router/router.ts
index 85b1c7b5e..817753b41 100644
--- a/packages/trpc/server/enterprise-router/router.ts
+++ b/packages/trpc/server/enterprise-router/router.ts
@@ -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,
diff --git a/packages/trpc/server/enterprise-router/sync-subscription.ts b/packages/trpc/server/enterprise-router/sync-subscription.ts
new file mode 100644
index 000000000..384cdc1a5
--- /dev/null
+++ b/packages/trpc/server/enterprise-router/sync-subscription.ts
@@ -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',
+ });
+ });
+ });
diff --git a/packages/trpc/server/enterprise-router/sync-subscription.types.ts b/packages/trpc/server/enterprise-router/sync-subscription.types.ts
new file mode 100644
index 000000000..a81703d56
--- /dev/null
+++ b/packages/trpc/server/enterprise-router/sync-subscription.types.ts
@@ -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;
+export type TSyncSubscriptionResponse = z.infer;
diff --git a/packages/trpc/server/organisation-router/create-organisation.ts b/packages/trpc/server/organisation-router/create-organisation.ts
index 63853b1d8..6aa8331e1 100644
--- a/packages/trpc/server/organisation-router/create-organisation.ts
+++ b/packages/trpc/server/organisation-router/create-organisation.ts
@@ -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 {