import type { Prisma } from '@prisma/client'; import type Stripe from 'stripe'; import { BillingSubscriptionStatus } from '@prisma/client'; import { db } from '@/lib/db'; import { getStripe, getStripePriceId } from '@/lib/stripe'; import { isStripeFeatureEnabled } from '@/lib/feature-flags'; const ACTIVE_SUBSCRIPTION_STATUSES = new Set([ BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING, ]); // Statuses that mean the customer already has a live Stripe subscription that // should be recovered (via the billing portal / dunning) rather than duplicated // with a fresh checkout. Everything else (FREE, CANCELED, INCOMPLETE_EXPIRED) // has no recoverable subscription, so a new checkout is appropriate. const RECOVERABLE_SUBSCRIPTION_STATUSES = new Set([ BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING, BillingSubscriptionStatus.PAST_DUE, BillingSubscriptionStatus.UNPAID, BillingSubscriptionStatus.INCOMPLETE, ]); export const DEFAULT_TRIAL_PERIOD_DAYS = 7; const STORAGE_CLEANUP_GRACE_DAYS = 15; type BillingAccessSubject = { subscriptionStatus: BillingSubscriptionStatus; trialEndsAt: Date | null; stripeCurrentPeriodEnd: Date | null; stripeCancelAtPeriodEnd?: boolean | null; stripeCancelAt?: Date | null; billingAccessEndedAt: Date | null; }; export function getDefaultTrialEndsAt(from: Date = new Date()) { return new Date(from.getTime() + DEFAULT_TRIAL_PERIOD_DAYS * 24 * 60 * 60 * 1000); } export function hasActiveTrial(trialEndsAt: Date | null | undefined, now: Date = new Date()) { return Boolean(trialEndsAt && trialEndsAt.getTime() > now.getTime()); } export function hasActiveSubscription(status: BillingSubscriptionStatus | null | undefined) { if (!status) return false; return ACTIVE_SUBSCRIPTION_STATUSES.has(status); } // True when the customer already has a live subscription (active/trialing OR a // recoverable one like past_due/unpaid/incomplete). Used to route them to the // billing portal instead of letting a new checkout create a duplicate. export function hasRecoverableSubscription(status: BillingSubscriptionStatus | null | undefined) { if (!status) return false; return RECOVERABLE_SUBSCRIPTION_STATUSES.has(status); } export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new Date()) { if (!isStripeFeatureEnabled()) { return true; } if (hasActiveSubscription(subject.subscriptionStatus)) { return true; } if (hasActiveTrial(subject.trialEndsAt, now)) { return true; } return Boolean( subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime() ); } export function getBillingAccessEndDate(subject: BillingAccessSubject) { if (subject.billingAccessEndedAt) { return subject.billingAccessEndedAt; } if (subject.stripeCurrentPeriodEnd) { return subject.stripeCurrentPeriodEnd; } return subject.trialEndsAt; } export function getStorageCleanupEligibleAt(subject: BillingAccessSubject) { const accessEndDate = getBillingAccessEndDate(subject); if (!accessEndDate) return null; return new Date(accessEndDate.getTime() + STORAGE_CLEANUP_GRACE_DAYS * 24 * 60 * 60 * 1000); } export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.UserWhereInput { if (!isStripeFeatureEnabled()) { return {}; } return { OR: [ { subscriptionStatus: { in: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING], }, }, { trialEndsAt: { gt: now } }, { stripeCurrentPeriodEnd: { gt: now } }, ], }; } export function buildExpiredBillingWhereInput(now: Date = new Date()): Prisma.UserWhereInput { const cleanupCutoff = new Date(now.getTime() - STORAGE_CLEANUP_GRACE_DAYS * 24 * 60 * 60 * 1000); // Without billing nothing can expire, so nobody is eligible. This used to fall through to // `NOT: {}`, which Prisma drops entirely, leaving a filter that matched on the grace period // alone: a self-hosted deployment running the cleanup script would delete the workspaces of // users it never charged. if (!isStripeFeatureEnabled()) { return { id: { in: [] } }; } // Spelled out as positive AND branches instead of `NOT: buildBillingAccessWhereInput(now)`. // Prisma renders that NOT as `NOT (status IN (...) OR "trialEndsAt" > $1 OR // "stripeCurrentPeriodEnd" > $2)`, and SQL comparisons against NULL are unknown rather than // false, so for a row with both dates empty the OR evaluates to NULL and NOT NULL is still // NULL: the row is never returned. Both columns empty is exactly what a canceled subscriber // looks like (markSubscriptionCanceledByCustomerId clears trialEndsAt, and Stripe no longer // reports current_period_end on the subscription), so the cleanup silently matched nobody. return { AND: [ { subscriptionStatus: { notIn: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING], }, }, { OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: now } }] }, { OR: [{ stripeCurrentPeriodEnd: null }, { stripeCurrentPeriodEnd: { lte: now } }] }, { OR: [ { billingAccessEndedAt: { lte: cleanupCutoff } }, { AND: [{ billingAccessEndedAt: null }, { trialEndsAt: { lte: cleanupCutoff } }], }, ], }, ], }; } export function mapStripeSubscriptionStatus( status: Stripe.Subscription.Status | null | undefined ): BillingSubscriptionStatus { switch (status) { case 'trialing': return BillingSubscriptionStatus.TRIALING; case 'active': return BillingSubscriptionStatus.ACTIVE; case 'past_due': return BillingSubscriptionStatus.PAST_DUE; case 'canceled': return BillingSubscriptionStatus.CANCELED; case 'unpaid': return BillingSubscriptionStatus.UNPAID; case 'incomplete': return BillingSubscriptionStatus.INCOMPLETE; case 'incomplete_expired': return BillingSubscriptionStatus.INCOMPLETE_EXPIRED; default: return BillingSubscriptionStatus.FREE; } } export function getBillingStatusLabel(status: BillingSubscriptionStatus) { switch (status) { case BillingSubscriptionStatus.TRIALING: return 'Trialing'; case BillingSubscriptionStatus.ACTIVE: return 'Active'; case BillingSubscriptionStatus.PAST_DUE: return 'Past due'; case BillingSubscriptionStatus.CANCELED: return 'Canceled'; case BillingSubscriptionStatus.UNPAID: return 'Unpaid'; case BillingSubscriptionStatus.INCOMPLETE: return 'Incomplete'; case BillingSubscriptionStatus.INCOMPLETE_EXPIRED: return 'Expired'; case BillingSubscriptionStatus.FREE: default: return 'Free'; } } export async function getStripeCheckoutState(userId: string) { const user = await db.user.findUnique({ where: { id: userId }, select: { subscriptionStatus: true, billingTrialConsumedAt: true, }, }); if (!user) { throw new Error(`User ${userId} not found`); } return { hasActiveSubscription: hasActiveSubscription(user.subscriptionStatus), hasRecoverableSubscription: hasRecoverableSubscription(user.subscriptionStatus), isTrialEligible: !user.billingTrialConsumedAt, }; } export async function getWorkspaceCreationEligibility(userId: string) { const [user, ownedWorkspaceCount, invitedWorkspaceCount, projectOnlyCollaborationCount] = await Promise.all([ db.user.findUnique({ where: { id: userId }, select: { subscriptionStatus: true, trialEndsAt: true, billingTrialConsumedAt: true, stripeCustomerId: true, stripeSubscriptionId: true, stripePriceId: true, stripeCurrentPeriodEnd: true, stripeCancelAtPeriodEnd: true, stripeCancelAt: true, billingAccessEndedAt: true, }, }), db.workspace.count({ where: { ownerId: userId }, }), db.workspaceMember.count({ where: { userId, workspace: { ownerId: { not: userId, }, }, }, }), db.projectMember.count({ where: { userId, project: { ownerId: { not: userId, }, workspace: { ownerId: { not: userId, }, }, }, }, }), ]); if (!user) { throw new Error(`User ${userId} not found`); } const billingAccess = hasBillingAccess(user); const collaborationCount = invitedWorkspaceCount + projectOnlyCollaborationCount; const canCreateWorkspace = !isStripeFeatureEnabled() || billingAccess || (ownedWorkspaceCount === 0 && collaborationCount === 0); let reason: string | null = null; if (!canCreateWorkspace && isStripeFeatureEnabled()) { if (collaborationCount > 0 && ownedWorkspaceCount === 0) { reason = 'You are currently collaborating in someone else’s workspace or project. Start a subscription to create a workspace of your own.'; } else { reason = 'Your trial has ended. Start a subscription to create and keep owning workspaces.'; } } return { canCreateWorkspace, reason, ownedWorkspaceCount, invitedWorkspaceCount, projectOnlyCollaborationCount, subscription: { status: user.subscriptionStatus, label: getBillingStatusLabel(user.subscriptionStatus), hasActiveSubscription: hasActiveSubscription(user.subscriptionStatus), hasRecoverableSubscription: hasRecoverableSubscription(user.subscriptionStatus), hasActiveTrial: hasActiveTrial(user.trialEndsAt), hasBillingAccess: billingAccess, isTrialEligible: !user.billingTrialConsumedAt, stripeCustomerId: user.stripeCustomerId, stripeSubscriptionId: user.stripeSubscriptionId, stripePriceId: user.stripePriceId, currentPeriodEnd: user.stripeCurrentPeriodEnd, cancelAtPeriodEnd: user.stripeCancelAtPeriodEnd, cancelAt: user.stripeCancelAt, trialEndsAt: user.trialEndsAt, billingAccessEndedAt: user.billingAccessEndedAt, storageCleanupEligibleAt: getStorageCleanupEligibleAt(user), }, }; } export async function getBillingOverview(userId: string) { const billing = await getWorkspaceCreationEligibility(userId); return { workspaceCreation: { canCreateWorkspace: billing.canCreateWorkspace, reason: billing.reason, ownedWorkspaceCount: billing.ownedWorkspaceCount, invitedWorkspaceCount: billing.invitedWorkspaceCount, }, subscription: billing.subscription, }; } export async function getOrCreateStripeCustomerId(userId: string) { const user = await db.user.findUnique({ where: { id: userId }, select: { id: true, email: true, name: true, stripeCustomerId: true, }, }); if (!user) { throw new Error(`User ${userId} not found`); } if (user.stripeCustomerId) { return user.stripeCustomerId; } const stripe = getStripe(); const customer = await stripe.customers.create({ email: user.email ?? undefined, name: user.name ?? undefined, metadata: { userId: user.id }, }); await db.user.update({ where: { id: user.id }, data: { stripeCustomerId: customer.id }, }); return customer.id; } function getStripeTimestamp(value: unknown): number | null { return typeof value === 'number' ? value : null; } function getInactiveBillingAccessEndedAt( subscription: Stripe.Subscription, currentPeriodEnd: number | null ) { const endedAt = getStripeTimestamp( (subscription as Stripe.Subscription & { ended_at?: unknown }).ended_at ); const canceledAt = getStripeTimestamp( (subscription as Stripe.Subscription & { canceled_at?: unknown }).canceled_at ); const reference = currentPeriodEnd ?? endedAt ?? canceledAt; return reference ? new Date(reference * 1000) : new Date(); } function getEntitledStripePriceId(subscription: Stripe.Subscription) { return hasEntitledPrice(subscription, getStripePriceId()) ? getStripePriceId() : null; } function hasEntitledPrice(subscription: Stripe.Subscription, configuredPriceId: string): boolean { return subscription.items.data.some((item) => item.price.id === configuredPriceId); } export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscription) { const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id; const user = await db.user.findUnique({ where: { stripeCustomerId: customerId }, select: { id: true, billingTrialConsumedAt: true, }, }); if (!user) { return null; } const currentPeriodEnd = 'current_period_end' in subscription && typeof subscription.current_period_end === 'number' ? subscription.current_period_end : null; const cancelAt = 'cancel_at' in subscription && typeof subscription.cancel_at === 'number' ? subscription.cancel_at : null; const cancelAtPeriodEnd = 'cancel_at_period_end' in subscription && typeof subscription.cancel_at_period_end === 'boolean' ? subscription.cancel_at_period_end : false; const trialEnd = 'trial_end' in subscription && typeof subscription.trial_end === 'number' ? subscription.trial_end : null; const entitledPriceId = getEntitledStripePriceId(subscription); const hasEntitledPrice = Boolean(entitledPriceId); const mappedStatus = hasEntitledPrice ? mapStripeSubscriptionStatus(subscription.status) : BillingSubscriptionStatus.FREE; const effectiveCurrentPeriodEnd = hasEntitledPrice && currentPeriodEnd ? new Date(currentPeriodEnd * 1000) : null; const effectiveTrialEnd = hasEntitledPrice && trialEnd ? new Date(trialEnd * 1000) : null; const hasAccess = hasEntitledPrice && (hasActiveSubscription(mappedStatus) || Boolean(currentPeriodEnd && currentPeriodEnd * 1000 > Date.now())); return db.user.update({ where: { id: user.id }, data: { stripeSubscriptionId: subscription.id, stripePriceId: entitledPriceId ?? subscription.items.data[0]?.price.id ?? null, stripeCurrentPeriodEnd: effectiveCurrentPeriodEnd, stripeCancelAtPeriodEnd: cancelAtPeriodEnd, stripeCancelAt: cancelAt ? new Date(cancelAt * 1000) : null, subscriptionStatus: mappedStatus, trialEndsAt: effectiveTrialEnd, billingTrialConsumedAt: hasEntitledPrice && trialEnd ? (user.billingTrialConsumedAt ?? new Date()) : user.billingTrialConsumedAt, billingAccessEndedAt: hasAccess ? null : getInactiveBillingAccessEndedAt(subscription, hasEntitledPrice ? currentPeriodEnd : null), }, }); } // A single Stripe customer can own several subscriptions at once (e.g. after // going past_due and re-subscribing). Higher priority = more authoritative for // deciding the user's entitlement. const SUBSCRIPTION_STATUS_PRIORITY: Record = { active: 100, trialing: 90, past_due: 80, unpaid: 70, paused: 60, incomplete: 50, incomplete_expired: 20, canceled: 10, }; // Picks the subscription that should drive the user's billing state when a // customer has more than one. Prefers subscriptions that carry the entitled // price, then the most "alive" status, then the most recently created. export function selectAuthoritativeSubscription( subscriptions: Stripe.Subscription[] ): Stripe.Subscription | null { if (subscriptions.length === 0) { return null; } // Read once, up front. Reading it inside the comparator meant a deployment with no // STRIPE_PRICE_ID configured worked for every customer holding one subscription and // threw only for those holding two, because a comparator never runs for a one-element // array. That is a miserable failure mode to diagnose in production. const configuredPriceId = getStripePriceId(); return [...subscriptions].sort((a, b) => { const aEntitled = hasEntitledPrice(a, configuredPriceId); const bEntitled = hasEntitledPrice(b, configuredPriceId); if (aEntitled !== bEntitled) { return aEntitled ? -1 : 1; } const aStatus = SUBSCRIPTION_STATUS_PRIORITY[a.status] ?? 0; const bStatus = SUBSCRIPTION_STATUS_PRIORITY[b.status] ?? 0; if (aStatus !== bStatus) { return bStatus - aStatus; } return (getStripeTimestamp(b.created) ?? 0) - (getStripeTimestamp(a.created) ?? 0); })[0]; } // Source-of-truth sync: instead of trusting a single subscription from a webhook // event body (which may be an OLD subscription being deleted while a NEWER one is // active), re-list ALL of the customer's subscriptions from Stripe and sync the // authoritative one. This is order-independent and self-healing. export async function syncStripeCustomerSubscriptions(customerId: string) { const stripe = getStripe(); const { data: subscriptions } = await stripe.subscriptions.list({ customer: customerId, status: 'all', limit: 100, }); const authoritative = selectAuthoritativeSubscription(subscriptions); if (!authoritative) { return markSubscriptionCanceledByCustomerId(customerId); } return syncStripeSubscriptionToUser(authoritative); } export async function markSubscriptionCanceledByCustomerId( customerId: string, options?: { currentPeriodEnd?: Date | null; endedAt?: Date | null } ) { const user = await db.user.findUnique({ where: { stripeCustomerId: customerId }, select: { id: true }, }); if (!user) { return null; } return db.user.update({ where: { id: user.id }, data: { subscriptionStatus: BillingSubscriptionStatus.CANCELED, trialEndsAt: null, stripeSubscriptionId: null, stripePriceId: null, stripeCurrentPeriodEnd: options?.currentPeriodEnd ?? null, stripeCancelAtPeriodEnd: false, stripeCancelAt: null, billingAccessEndedAt: options?.endedAt ?? options?.currentPeriodEnd ?? new Date(), }, }); }