fix: rework stripe webhooks into idempotent subscription sync (#2977)

Replace per-event webhook handlers with a single sync function that
fetches the current state from Stripe and converges the local
subscription, claim, and organisation type.

- Create organisations upfront before checkout, restricted as
  "pending payment" until the first payment syncs
- Add rate-limited subscription sync route, triggered on checkout
  success so the UI doesn't wait on webhooks
- Surface pending payment state in banner, billing table, and limits
This commit is contained in:
Lucas Smith
2026-06-12 16:01:03 +10:00
committed by GitHub
parent b84b87cea6
commit 3887aa67c8
19 changed files with 667 additions and 664 deletions
+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;
};