Files
OpenFrame/lib/billing.ts
T

1386 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Prisma } from '@prisma/client';
import type Stripe from 'stripe';
import { BillingSubscriptionStatus, InvitationStatus } from '@prisma/client';
import { db } from '@/lib/db';
import { getStripe, getStripePriceId } from '@/lib/stripe';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { recordSubscriptionTransition } from '@/lib/analytics/billing-events';
import { eventKey, recordEvent } from '@/lib/analytics/record';
import { TRIAL_WORKSPACE_LIMIT } from '@/lib/trial-limits';
const ACTIVE_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
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>([
BillingSubscriptionStatus.ACTIVE,
BillingSubscriptionStatus.TRIALING,
BillingSubscriptionStatus.PAST_DUE,
BillingSubscriptionStatus.UNPAID,
BillingSubscriptionStatus.INCOMPLETE,
]);
// Statuses that mean no payment on this subscription has ever gone through.
// Stripe stamps a current period on an `incomplete` subscription all the same,
// so a checkout whose first charge failed leaves `stripeCurrentPeriodEnd` a
// month into the future with nothing paid behind it. Every other status in the
// enum follows at least one successful charge, or has no period end at all.
const UNPAID_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
BillingSubscriptionStatus.INCOMPLETE,
BillingSubscriptionStatus.INCOMPLETE_EXPIRED,
]);
// The Stripe-side counterpart of RECOVERABLE_SUBSCRIPTION_STATUSES, for the places that
// hold a raw Stripe subscription rather than the mirrored status. Deliberately the same
// membership: a subscription worth cancelling is a subscription worth blocking a second
// checkout over, and two sets that disagreed only produced a Cancel button that always
// failed and a checkout guard weaker than the mirror it was backing up.
const LIVE_STRIPE_STATUSES = new Set<Stripe.Subscription.Status>([
'active',
'trialing',
'past_due',
'unpaid',
'incomplete',
]);
// Cancelling one of these takes effect immediately: the open period was never paid for,
// so there is nothing left to run out.
const UNPAID_STRIPE_STATUSES = new Set<Stripe.Subscription.Status>([
'past_due',
'unpaid',
'incomplete',
]);
// A subscription that was running and then missed a payment. It keeps access while Stripe
// retries the card, so a customer whose card expired is not locked out before they have
// had a chance to fix it. `incomplete` is not here: nothing has ever been paid on it.
const RETRYING_STRIPE_STATUSES = new Set<Stripe.Subscription.Status>(['past_due', 'unpaid']);
// Application grace period, independent of the Stripe retry settings. An unpaid
// invoice's future period end does not extend this access window.
const UNPAID_ACCESS_GRACE_DAYS = 14;
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());
}
/**
* The trial end date to keep when a Stripe sync has none of its own.
*
* An unexpired trial is an entitlement the account already holds, so billing
* state may add access but must never take a trial back before it has run out.
* Without this, a trial user who starts a checkout and abandons the card step
* lands on an `incomplete` subscription carrying no `trial_end`, and the sync
* would write `trialEndsAt: null` over their remaining days and lock them out of
* a product they were still entitled to. Nothing can be farmed this way either:
* `billingTrialConsumedAt` is what makes the trial once-per-account, and it is
* never cleared.
*/
export function keepUnexpiredTrial(trialEndsAt: Date | null | undefined, now: Date = new Date()) {
return hasActiveTrial(trialEndsAt, now) ? (trialEndsAt ?? null) : null;
}
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);
}
/**
* Whether this account is a paying customer, as opposed to one that merely has
* access right now.
*
* The cardless trial makes these two different questions for the first time: a
* trial account passes `hasBillingAccess` with no card and no Stripe customer
* behind it. Every ceiling that exists to bound what an unpaid account can cost
* us (storage, upload size, workspace count) hangs off this, not off access.
* A legacy Stripe trial counts as paid because a card was handed over for it.
*/
export function isPaidTier(
subject: Pick<
BillingAccessSubject,
'subscriptionStatus' | 'stripeCurrentPeriodEnd' | 'billingAccessEndedAt'
>,
now: Date = new Date()
) {
if (!isStripeFeatureEnabled()) {
return true;
}
if (hasActiveSubscription(subject.subscriptionStatus)) {
return true;
}
// The period end alone is not proof of payment.
if (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)) {
return false;
}
// Same cutoff `hasBillingAccess` applies, so the two cannot disagree about a customer
// behind on payment. They did once: access stopped at the end of the payment grace window
// while this kept saying "paid" for the rest of the period, which left the account with
// no banner explaining the lockout and able to create workspaces it could not then see.
if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) {
return false;
}
return Boolean(
subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime()
);
}
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;
}
// Everything below decides whether the reported period still stands in for access, and
// the two guards exist because it very often does not. Both are scoped to this branch
// rather than applied at the top of the function: `billingAccessEndedAt` is only ever
// cleared by a Stripe sync, so a stale one from a lapsed subscription would otherwise
// outrank a freshly started cardless trial and burn the account's one trial for nothing.
// Stripe stamps a period on a subscription whose first charge never went through, so
// that period is not evidence of payment. The same rejection `isPaidTier` makes.
if (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)) {
return false;
}
// Stripe advances the period the moment it issues the renewal invoice, paid or not, and
// the period survives cancellation, so on its own it would hand a full free month to
// anyone whose renewal fails. This is the bound: a subscription behind on payment is
// stamped with the end of the payment grace window, a cancelled one with `ended_at`.
if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) {
return false;
}
return Boolean(
subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime()
);
}
export function getBillingAccessEndDate(subject: BillingAccessSubject) {
const subscriptionEnd =
subject.billingAccessEndedAt ??
(UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)
? null
: subject.stripeCurrentPeriodEnd);
// A trial grants access independently of the subscription cutoff. Retention starts
// after the last legitimate entitlement, never from an unpaid invoice's period.
if (!subscriptionEnd) return subject.trialEndsAt;
if (!subject.trialEndsAt) return subscriptionEnd;
return new Date(Math.max(subscriptionEnd.getTime(), subject.trialEndsAt.getTime()));
}
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 {};
}
// Mirrors `hasBillingAccess` branch for branch, including the two guards scoped to its
// period-end arm, so the query and the in-memory check cannot disagree about who still
// has access.
return {
OR: [
{
subscriptionStatus: {
in: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING],
},
},
{ trialEndsAt: { gt: now } },
{
stripeCurrentPeriodEnd: { gt: now },
subscriptionStatus: { notIn: [...UNPAID_SUBSCRIPTION_STATUSES] },
OR: [{ billingAccessEndedAt: null }, { billingAccessEndedAt: { 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: [] } };
}
// Match the same last entitlement date as getBillingAccessEndDate, with explicit
// null branches because SQL comparisons against null do not evaluate to false.
return {
AND: [
{
subscriptionStatus: {
notIn: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING],
},
},
{ OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: cleanupCutoff } }] },
{
OR: [
{ billingAccessEndedAt: { lte: cleanupCutoff } },
{
billingAccessEndedAt: null,
subscriptionStatus: { notIn: [...UNPAID_SUBSCRIPTION_STATUSES] },
stripeCurrentPeriodEnd: { lte: cleanupCutoff },
},
{
billingAccessEndedAt: null,
trialEndsAt: { lte: cleanupCutoff },
OR: [
{ stripeCurrentPeriodEnd: null },
{ subscriptionStatus: { in: [...UNPAID_SUBSCRIPTION_STATUSES] } },
],
},
],
},
],
};
}
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';
}
}
/**
* A `where` matching the accounts whose only entitlement is a running cardless
* trial: no Stripe subscription behind them, so `subscriptionStatus` is FREE.
*/
export function buildCardlessTrialWhereInput(now: Date = new Date()): Prisma.UserWhereInput {
return {
subscriptionStatus: BillingSubscriptionStatus.FREE,
trialEndsAt: { gt: now },
};
}
/**
* The status to show for an account, which is not always the one Stripe stored.
*
* The cardless trial writes `trialEndsAt` and nothing else, because there is no
* Stripe subscription behind it to report `trialing`. `subscriptionStatus` stays
* FREE, so anything reading that column alone showed a running trial as a free
* account: the admin dashboard counted every trial under "Free Users" and left
* "On Trial" at zero. Access is already resolved from the date in
* `hasBillingAccess`, so what is displayed follows the same date.
*
* Only FREE is overridden. Every other status means Stripe has an opinion about
* this account (an abandoned checkout leaves INCOMPLETE while the trial runs on),
* and that opinion is the more useful of the two to show.
*/
export function getEffectiveBillingStatus(
subject: Pick<BillingAccessSubject, 'subscriptionStatus' | 'trialEndsAt'>,
now: Date = new Date()
): BillingSubscriptionStatus {
if (
subject.subscriptionStatus === BillingSubscriptionStatus.FREE &&
hasActiveTrial(subject.trialEndsAt, now)
) {
return BillingSubscriptionStatus.TRIALING;
}
return subject.subscriptionStatus;
}
/**
* A `where` that filters on the displayed status rather than the stored one, so
* an admin asking for "Trialing" is handed the cardless trials and one asking
* for "Free" is not.
*/
export function buildEffectiveBillingStatusWhereInput(
status: BillingSubscriptionStatus,
now: Date = new Date()
): Prisma.UserWhereInput {
if (status === BillingSubscriptionStatus.TRIALING) {
return {
OR: [
{ subscriptionStatus: BillingSubscriptionStatus.TRIALING },
buildCardlessTrialWhereInput(now),
],
};
}
if (status === BillingSubscriptionStatus.FREE) {
return {
subscriptionStatus: BillingSubscriptionStatus.FREE,
OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: now } }],
};
}
return { subscriptionStatus: status };
}
/**
* Grants the cardless trial, once per account, and reports whether this call is
* the one that granted it.
*
* Called where the email address is proven rather than where the account is
* created: an unverifiable address gets no trial, which is the cheapest abuse
* control available and the reason the two writes below can stay this simple.
*
* `billingTrialConsumedAt` is written here rather than only by the Stripe sync.
* It is the once-per-account marker, so a re-issued verification link, a second
* device or a replayed request all land on the `WHERE` clause and change nothing.
*
* Signup goes through `startCardlessTrialOnSignup` instead, which holds the trial
* back for an account that only exists because somebody invited it. This is the
* unconditional grant, reached later only when that account explicitly asks for
* its deferred trial through the start-trial endpoint. It is never started as a
* side effect of some other action; the clock costs the account its only trial.
*/
export async function startCardlessTrial(userId: string, now: Date = new Date()) {
// Without billing nothing is gated, so a trial would be a date nobody reads.
// Writing one anyway would consume the trial of a self-hosted instance that
// later switches billing on.
if (!isStripeFeatureEnabled()) {
return false;
}
const { count } = await db.user.updateMany({
where: { id: userId, trialEndsAt: null, billingTrialConsumedAt: null },
data: {
trialEndsAt: getDefaultTrialEndsAt(now),
billingTrialConsumedAt: now,
},
});
if (count === 0) {
return false;
}
await recordEvent({
name: 'TRIAL_STARTED',
dedupeKey: eventKey('TRIAL_STARTED', userId),
userId,
});
return true;
}
/**
* Whether this account arrived as somebody else's collaborator.
*
* An invited member works inside the inviter's workspace on the inviter's
* billing, so a trial handed to them at signup buys them nothing and is spent
* before they have seen the product on an account of their own. Worse, it is
* spent for good: `billingTrialConsumedAt` is never cleared, so the day they
* consider becoming a customer themselves the trial is already gone.
*
* Two signals, because the invitation lands at different points on the two
* signup paths. The credentials route accepts the token inside the same request
* that creates the account, so by the time the trial is considered the
* membership row exists. An OAuth signup creates the account on the way out to
* the provider and accepts the invitation only on the way back, so there the
* pending invitation is the only thing to go on.
*/
async function arrivedAsCollaborator(userId: string, now: Date) {
const user = await db.user.findUnique({
where: { id: userId },
select: { email: true },
});
const [workspaceMemberships, projectMemberships, pendingInvitations] = await Promise.all([
db.workspaceMember.count({
where: { userId, workspace: { ownerId: { not: userId } } },
}),
db.projectMember.count({
where: { userId, project: { ownerId: { not: userId } } },
}),
user?.email
? db.invitation.count({
where: {
email: user.email,
status: InvitationStatus.PENDING,
expiresAt: { gt: now },
},
})
: Promise.resolve(0),
]);
return workspaceMemberships > 0 || projectMemberships > 0 || pendingInvitations > 0;
}
/**
* The trial as granted at signup: to everyone except an invited collaborator,
* whose clock is deferred until they own something of their own.
*
* Nothing is lost by waiting. The deferred trial stays claimable forever: the
* account starts it whenever it chooses through the start-trial endpoint, which
* the workspace-creation and billing screens point at.
*/
export async function startCardlessTrialOnSignup(userId: string, now: Date = new Date()) {
if (!isStripeFeatureEnabled()) {
return false;
}
if (await arrivedAsCollaborator(userId, now)) {
return false;
}
return startCardlessTrial(userId, now);
}
export async function getStripeCheckoutState(userId: string) {
const user = await db.user.findUnique({
where: { id: userId },
select: {
subscriptionStatus: true,
},
});
if (!user) {
throw new Error(`User ${userId} not found`);
}
return {
hasActiveSubscription: hasActiveSubscription(user.subscriptionStatus),
hasRecoverableSubscription: hasRecoverableSubscription(user.subscriptionStatus),
};
}
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 isPaid = isPaidTier(user);
const collaborationCount = invitedWorkspaceCount + projectOnlyCollaborationCount;
// An invited collaborator whose trial was deferred at signup. Their trial is
// still owed, but starting it is their call, not a side effect of clicking
// "create workspace": the clock costs them their only trial, so it runs only
// after they ask for it through the explicit start-trial endpoint.
const canStartTrial =
isStripeFeatureEnabled() && !billingAccess && !user.trialEndsAt && !user.billingTrialConsumedAt;
// A paying account creates as many workspaces as it wants. Everyone else gets
// one, which covers both the cardless trial and the pre-trial state where an
// account may set a workspace up before it can open it.
const canCreateWorkspace =
!isStripeFeatureEnabled() ||
isPaid ||
((billingAccess || collaborationCount === 0) && ownedWorkspaceCount < TRIAL_WORKSPACE_LIMIT);
let reason: string | null = null;
if (!canCreateWorkspace && isStripeFeatureEnabled()) {
if (billingAccess && ownedWorkspaceCount >= TRIAL_WORKSPACE_LIMIT) {
reason = 'Your free trial includes one workspace. Subscribe to create more.';
} else if (canStartTrial && collaborationCount > 0 && ownedWorkspaceCount === 0) {
reason =
'You are collaborating in someone elses workspace, so your free trial has not started yet. Start it to create a workspace of your own.';
} else {
reason = 'Your trial has ended. Start a subscription to create and keep owning workspaces.';
}
}
return {
canCreateWorkspace,
canStartTrial,
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,
isPaid,
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,
canStartTrial: billing.canStartTrial,
reason: billing.reason,
ownedWorkspaceCount: billing.ownedWorkspaceCount,
invitedWorkspaceCount: billing.invitedWorkspaceCount,
},
subscription: billing.subscription,
};
}
/** How long before the trial runs out the countdown starts being shown. */
export const TRIAL_ENDING_NOTICE_DAYS = 3;
export interface TrialNotice {
/** `ending` while access is still live, `ended` once it has lapsed. */
kind: 'ending' | 'ended';
endsAt: Date;
/** When the cleanup job becomes eligible to delete this account's media. */
contentKeptUntil: Date | null;
}
/**
* The one-line trial status worth interrupting somebody with, or null.
*
* Both halves of the deadline are in one place because the useful message is the
* pair: an account is told when the trial runs out and, separately, that running
* out is not the moment its work disappears. The gap between those two dates is
* the fifteen-day cleanup grace period, and until now nothing in the product said
* it out loud, which made the end of a trial read as a deletion notice.
*/
export async function getTrialNotice(
userId: string,
now: Date = new Date()
): Promise<TrialNotice | null> {
if (!isStripeFeatureEnabled()) {
return null;
}
const user = await db.user.findUnique({
where: { id: userId },
select: {
subscriptionStatus: true,
trialEndsAt: true,
stripeCurrentPeriodEnd: true,
billingAccessEndedAt: true,
},
});
// A paying account has a billing period, not a trial, and gets told about it
// in settings rather than in a banner on every page.
if (!user || isPaidTier(user, now)) {
return null;
}
const contentKeptUntil = getStorageCleanupEligibleAt(user);
const notice = ((): TrialNotice | null => {
if (hasActiveTrial(user.trialEndsAt, now) && user.trialEndsAt) {
const daysLeft = (user.trialEndsAt.getTime() - now.getTime()) / (24 * 60 * 60 * 1000);
if (daysLeft > TRIAL_ENDING_NOTICE_DAYS) {
return null;
}
return { kind: 'ending', endsAt: user.trialEndsAt, contentKeptUntil };
}
const endsAt = getBillingAccessEndDate(user);
if (!endsAt || hasBillingAccess(user, now)) {
return null;
}
// Past the cleanup date there is nothing left to reassure anybody about.
if (contentKeptUntil && contentKeptUntil.getTime() <= now.getTime()) {
return null;
}
return { kind: 'ended', endsAt, contentKeptUntil };
})();
// Neither sentence is true for a guest in somebody else's workspace: no
// deadline is coming for them, and the media the banner promises to keep is
// not theirs and is not at risk. They were reading "your projects and media
// are kept until" about a paying customer's work. Checked last so the queries
// only run for the few accounts a banner was about to be shown to.
if (notice && (await isCollaboratorWithNothingOfTheirOwn(userId, now))) {
return null;
}
return notice;
}
/**
* Somebody who only ever works inside workspaces they do not own.
*
* Ownership is what makes billing personal: the storage, the projects and the
* cleanup deadline all hang off the owning account. An account that owns none of
* that, and reaches the product entirely through a workspace whose owner is
* paying, has nothing of its own on the line.
*/
async function isCollaboratorWithNothingOfTheirOwn(userId: string, now: Date) {
const [ownedWorkspaceCount, collaborationCount] = await Promise.all([
db.workspace.count({ where: { ownerId: userId } }),
db.workspace.count({
where: {
ownerId: { not: userId },
owner: buildBillingAccessWhereInput(now),
OR: [
{ members: { some: { userId } } },
{ projects: { some: { members: { some: { userId } } } } },
],
},
}),
]);
return ownedWorkspaceCount === 0 && collaborationCount > 0;
}
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;
}
/**
* The billing period moved off the subscription and onto its items in the Basil API
* version, so reading `subscription.current_period_end` yields undefined on every current
* version. Webhook payloads can still be rendered at an older version, so the legacy field
* is kept as a fallback rather than dropped.
*/
export function getSubscriptionPeriodEnd(subscription: Stripe.Subscription): number | null {
const itemPeriodEnds = (subscription.items?.data ?? [])
.map((item) =>
getStripeTimestamp(
(item as Stripe.SubscriptionItem & { current_period_end?: unknown }).current_period_end
)
)
.filter((value): value is number => value !== null);
if (itemPeriodEnds.length > 0) {
return Math.max(...itemPeriodEnds);
}
return getStripeTimestamp(
(subscription as Stripe.Subscription & { current_period_end?: unknown }).current_period_end
);
}
/**
* Same field move as the period end. Stripe opens the new period when it issues the
* renewal invoice, so for an unpaid subscription this is roughly when the first payment
* attempt failed, which is what the retry window is measured from.
*/
export function getSubscriptionPeriodStart(subscription: Stripe.Subscription): number | null {
const itemPeriodStarts = (subscription.items?.data ?? [])
.map((item) =>
getStripeTimestamp(
(item as Stripe.SubscriptionItem & { current_period_start?: unknown }).current_period_start
)
)
.filter((value): value is number => value !== null);
if (itemPeriodStarts.length > 0) {
return Math.min(...itemPeriodStarts);
}
return getStripeTimestamp(
(subscription as Stripe.Subscription & { current_period_start?: unknown }).current_period_start
);
}
/**
* The invoice link to its subscription moved under `parent.subscription_details` in the
* Basil API version. Same fallback reasoning as the period above.
*/
export function getInvoiceSubscriptionId(invoice: Stripe.Invoice): string | null {
const fromParent = invoice.parent?.subscription_details?.subscription;
if (typeof fromParent === 'string') return fromParent;
if (fromParent && typeof fromParent === 'object') return fromParent.id;
const legacy = (invoice as Stripe.Invoice & { subscription?: unknown }).subscription;
if (typeof legacy === 'string') return legacy;
if (legacy && typeof legacy === 'object' && 'id' in legacy) {
const id = (legacy as { id: unknown }).id;
return typeof id === 'string' ? id : null;
}
return 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
);
// `ended_at` wins over everything: a subscription killed mid-period for non-payment
// must not keep access until a period the customer never paid for.
if (endedAt) {
return new Date(endedAt * 1000);
}
// Still running, just behind on payment: bound access to the application grace period,
// not the period end Stripe advanced to cover the unpaid invoice. The
// period start is when that invoice was issued, so it is what the window runs from; when
// it is missing (a paginated item list, an older payload shape) the window runs from now
// instead. Falling through to "ended" here would lock out the customer this branch
// exists to keep in, which is the wrong way to fail on missing data.
if (RETRYING_STRIPE_STATUSES.has(subscription.status)) {
const grace = UNPAID_ACCESS_GRACE_DAYS * 24 * 60 * 60;
const periodStart = getSubscriptionPeriodStart(subscription);
const graceEnd = periodStart ? periodStart + grace : Math.floor(Date.now() / 1000) + grace;
return new Date(Math.min(graceEnd, currentPeriodEnd ?? graceEnd) * 1000);
}
// Preserve the existing period-based access policy for paused subscriptions.
// The paused status itself is not evidence that this period was paid.
if (subscription.status === 'paused' && currentPeriodEnd) {
return new Date(currentPeriodEnd * 1000);
}
// Anything else that gets here never paid for the period Stripe is reporting, so that
// period is not a date access can run to. `incomplete` and `incomplete_expired` are the
// cases that matter: their very first payment never went through.
return canceledAt ? new Date(canceledAt * 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) {
return recordSyncedSubscription(await writeStripeSubscriptionToUser(subscription, db));
}
async function writeStripeSubscriptionToUser(
subscription: Stripe.Subscription,
client: Prisma.TransactionClient
) {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
const user = await client.user.findUnique({
where: { stripeCustomerId: customerId },
select: {
id: true,
billingTrialConsumedAt: true,
// Read so a cardless trial that has not run out survives this sync.
trialEndsAt: true,
// Read for the funnel: the transition is what gets recorded, so the state
// being overwritten has to be captured before the update below.
subscriptionStatus: true,
stripeCancelAtPeriodEnd: true,
},
});
if (!user) {
return null;
}
const currentPeriodEnd = getSubscriptionPeriodEnd(subscription);
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;
// Stripe grants no trials any more, so `effectiveTrialEnd` is null for every
// subscription created after the cardless trial shipped, and this fallback is
// what stops an abandoned or failed checkout from erasing the days the account
// still had. Legacy card-backed trials keep arriving through the branch above.
const preservedTrialEnd = effectiveTrialEnd ?? user.trialEndsAt ?? null;
// The reported period is not proof of payment: Stripe advances it when it issues the
// renewal invoice, paid or not, and it survives cancellation. Access therefore follows
// the status, and every other case gets a cutoff stamped into `billingAccessEndedAt`,
// which is cleared again as soon as the subscription goes back to active.
const hasAccess = hasEntitledPrice && hasActiveSubscription(mappedStatus);
const updated = await client.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: preservedTrialEnd,
billingTrialConsumedAt:
hasEntitledPrice && trialEnd
? (user.billingTrialConsumedAt ?? new Date())
: user.billingTrialConsumedAt,
// Preserve the subscription cutoff even during a trial. The trial has its own
// access branch; clearing this cutoff would resurrect an unpaid period later.
billingAccessEndedAt: hasAccess
? null
: getInactiveBillingAccessEndedAt(subscription, hasEntitledPrice ? currentPeriodEnd : null),
},
});
const transition: Parameters<typeof recordSubscriptionTransition>[0] = {
userId: user.id,
subscriptionId: subscription.id,
before: {
status: user.subscriptionStatus,
cancelAtPeriodEnd: user.stripeCancelAtPeriodEnd,
hadTrial: user.billingTrialConsumedAt !== null,
},
after: {
status: mappedStatus,
cancelAtPeriodEnd,
trialEndsAt: preservedTrialEnd,
currentPeriodEnd: effectiveCurrentPeriodEnd,
},
};
return { updated, transition };
}
async function recordSyncedSubscription(
result: Awaited<ReturnType<typeof writeStripeSubscriptionToUser>>
) {
if (!result) return null;
// Analytics uses its own connection. Run it after commit, not while a billing
// transaction holds a connection and other syncs are queued on its advisory lock.
await recordSubscriptionTransition(result.transition);
return result.updated;
}
// 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<Stripe.Subscription.Status, number> = {
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. The customer lock covers the Stripe read as well as the mirror
// write: locking only after the read would still let a delayed older response win.
export async function syncStripeCustomerSubscriptions(customerId: string) {
const result = await db.$transaction(
async (tx) => {
// Two-key advisory locks occupy a separate namespace from the one-key
// cancellation locks. Cancellation releases its lock before calling sync.
await tx.$executeRaw`
SELECT pg_advisory_xact_lock(hashtext('stripe-subscription-sync'), hashtext(${customerId}))
`;
const { data: subscriptions } = await getStripe().subscriptions.list({
customer: customerId,
status: 'all',
limit: 100,
});
const authoritative = selectAuthoritativeSubscription(subscriptions);
return authoritative
? writeStripeSubscriptionToUser(authoritative, tx)
: writeSubscriptionCanceledByCustomerId(customerId, undefined, tx);
},
// Bound lock and connection occupancy. A slow Stripe call or lock wait fails
// this sync; writes through the expired transaction cannot overwrite a newer sync.
{ maxWait: 10_000, timeout: 30_000 }
);
return recordSyncedSubscription(result);
}
export async function markSubscriptionCanceledByCustomerId(
customerId: string,
options?: { currentPeriodEnd?: Date | null; endedAt?: Date | null }
) {
return recordSyncedSubscription(
await writeSubscriptionCanceledByCustomerId(customerId, options, db)
);
}
async function writeSubscriptionCanceledByCustomerId(
customerId: string,
options: { currentPeriodEnd?: Date | null; endedAt?: Date | null } | undefined,
client: Prisma.TransactionClient
) {
const user = await client.user.findUnique({
where: { stripeCustomerId: customerId },
select: {
id: true,
subscriptionStatus: true,
stripeSubscriptionId: true,
stripeCancelAtPeriodEnd: true,
stripeCurrentPeriodEnd: true,
billingTrialConsumedAt: true,
trialEndsAt: true,
},
});
if (!user) {
return null;
}
// Losing the subscription does not retract a trial that has not run out. The
// account keeps the days it was given and lands back on the trial's own end
// date, which is also what the cancellation copy in settings promises.
const preservedTrialEnd = user.trialEndsAt ?? null;
const updated = await client.user.update({
where: { id: user.id },
data: {
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
trialEndsAt: preservedTrialEnd,
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: options?.currentPeriodEnd ?? null,
stripeCancelAtPeriodEnd: false,
stripeCancelAt: null,
billingAccessEndedAt: options?.endedAt ?? options?.currentPeriodEnd ?? new Date(),
},
});
// Reached when the customer has no subscriptions left at all. The cycle marker
// uses the period end being cleared here, which is the same one the earlier
// "cancel at period end" write carried, so a customer who cancelled through the
// portal and then reached the end of their term produces one cancellation, not two.
const transition: Parameters<typeof recordSubscriptionTransition>[0] = {
userId: user.id,
subscriptionId: user.stripeSubscriptionId ?? user.id,
before: {
status: user.subscriptionStatus,
cancelAtPeriodEnd: user.stripeCancelAtPeriodEnd,
hadTrial: user.billingTrialConsumedAt !== null,
},
after: {
status: BillingSubscriptionStatus.CANCELED,
cancelAtPeriodEnd: false,
trialEndsAt: preservedTrialEnd,
currentPeriodEnd: options?.currentPeriodEnd ?? user.stripeCurrentPeriodEnd ?? null,
},
};
return { updated, transition };
}
/**
* Returns a subscription of this customer that still grants access, if any. A customer can
* hold several at once, so the state of one says nothing about the others.
*/
export async function findLiveStripeSubscription(customerId: string) {
const stripe = getStripe();
const { data: subscriptions } = await stripe.subscriptions.list({
customer: customerId,
status: 'all',
limit: 100,
});
return (
selectAuthoritativeSubscription(
subscriptions.filter((subscription) => LIVE_STRIPE_STATUSES.has(subscription.status))
) ?? null
);
}
/**
* Asked before opening checkout. Answered by Stripe rather than by the local mirror: the
* mirror can be stale or cleared, and a customer who slips past this ends up paying for two
* subscriptions at once.
*/
export async function findBlockingStripeSubscription(customerId: string) {
const stripe = getStripe();
const { data: subscriptions } = await stripe.subscriptions.list({
customer: customerId,
status: 'all',
limit: 100,
});
return (
subscriptions.find((subscription) => LIVE_STRIPE_STATUSES.has(subscription.status)) ?? null
);
}
export function isUnpaidStripeSubscription(subscription: Stripe.Subscription) {
return UNPAID_STRIPE_STATUSES.has(subscription.status);
}
/** Only a wholly unpaid, ordinary current-period invoice can be written off. */
export function isCurrentSubscriptionInvoice(
invoice: Stripe.Invoice,
subscription: Stripe.Subscription
): boolean {
const latestId =
typeof subscription.latest_invoice === 'string'
? subscription.latest_invoice
: subscription.latest_invoice?.id;
const start = getSubscriptionPeriodStart(subscription);
const end = getSubscriptionPeriodEnd(subscription);
if (
invoice.id !== latestId ||
invoice.status !== 'open' ||
invoice.amount_paid !== 0 ||
!['subscription_cycle', 'subscription_create'].includes(invoice.billing_reason ?? '') ||
getInvoiceSubscriptionId(invoice) !== subscription.id ||
start === null ||
end === null ||
!invoice.lines ||
invoice.lines.has_more ||
invoice.lines.data.length === 0
)
return false;
return invoice.lines.data.every((line) => {
const details = line.parent?.subscription_item_details;
return (
line.parent?.type === 'subscription_item_details' &&
details?.subscription === subscription.id &&
details.proration === false &&
line.pricing?.price_details?.price === getStripePriceId() &&
line.period.start === start &&
line.period.end === end
);
});
}
async function listOpenSubscriptionInvoices(customerId: string, subscriptionId: string) {
const invoices: Stripe.Invoice[] = [];
let startingAfter: string | undefined;
while (true) {
const page = await getStripe().invoices.list({
customer: customerId,
status: 'open',
limit: 100,
...(startingAfter ? { starting_after: startingAfter } : {}),
});
invoices.push(
...page.data.filter((invoice) => getInvoiceSubscriptionId(invoice) === subscriptionId)
);
if (!page.has_more || page.data.length === 0) break;
startingAfter = page.data[page.data.length - 1].id;
}
return invoices;
}
/**
* Stop automatic collection on all open invoices for this subscription. Older or
* mixed invoices remain receivables; only a complete current renewal is voided.
* Failures propagate so callers can report and retry unfinished cleanup.
*/
export async function voidOpenSubscriptionInvoices(
customerId: string,
subscriptionId: string,
subscriptionSnapshot?: Stripe.Subscription
) {
const stripe = getStripe();
const subscription =
subscriptionSnapshot ?? (await stripe.subscriptions.retrieve(subscriptionId));
const customer =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
if (customer !== customerId) throw new Error('Subscription customer mismatch');
const voided: string[] = [];
for (const invoice of await listOpenSubscriptionInvoices(customerId, subscriptionId)) {
// Immediate cancellation normally pauses collection too. Explicitly keep retained
// receivables paused, including when retrying a partly completed cancellation.
if (invoice.auto_advance) await stripe.invoices.update(invoice.id, { auto_advance: false });
if (isCurrentSubscriptionInvoice(invoice, subscription)) {
await stripe.invoices.voidInvoice(invoice.id);
voided.push(invoice.id);
}
}
return voided;
}
/** Cancellation candidates differ from the subscription granting access. */
export async function findCancelableStripeSubscription(customerId: string) {
const subscriptions: Stripe.Subscription[] = [];
let startingAfter: string | undefined;
while (true) {
const page = await getStripe().subscriptions.list({
customer: customerId,
status: 'all',
limit: 100,
...(startingAfter ? { starting_after: startingAfter } : {}),
});
subscriptions.push(...page.data);
if (!page.has_more || page.data.length === 0) break;
startingAfter = page.data[page.data.length - 1].id;
}
const candidate = selectAuthoritativeSubscription(
subscriptions.filter(
(subscription) =>
hasEntitledPrice(subscription, getStripePriceId()) &&
LIVE_STRIPE_STATUSES.has(subscription.status) &&
(isUnpaidStripeSubscription(subscription) ||
(!subscription.cancel_at && !subscription.cancel_at_period_end))
)
);
if (candidate) return candidate;
// A failed invoice write must remain reachable after Stripe accepted cancellation.
for (const subscription of subscriptions) {
if (
!['canceled', 'incomplete_expired'].includes(subscription.status) ||
!hasEntitledPrice(subscription, getStripePriceId())
)
continue;
const invoices = await listOpenSubscriptionInvoices(customerId, subscription.id);
if (
invoices.some(
(invoice) => invoice.auto_advance || isCurrentSubscriptionInvoice(invoice, subscription)
)
) {
return subscription;
}
}
return null;
}
/**
* Scoped to a subscription when one is known, the same way `voidOpenSubscriptionInvoices`
* is: a customer can carry an open invoice left behind by a subscription they no longer
* hold, and pointing them at that one does nothing about the retries they are seeing.
*/
export async function getOpenInvoiceForCustomer(
customerId: string,
subscriptionId?: string | null
) {
const stripe = getStripe();
const { data: invoices } = await stripe.invoices.list({
customer: customerId,
status: 'open',
limit: 100,
});
const candidates = subscriptionId
? invoices.filter((invoice) => getInvoiceSubscriptionId(invoice) === subscriptionId)
: invoices;
const newest = candidates
.slice()
.sort((a, b) => (b.created ?? 0) - (a.created ?? 0))
.at(0);
if (!newest) {
return null;
}
return {
id: newest.id ?? null,
hostedInvoiceUrl: newest.hosted_invoice_url ?? null,
amountDue: newest.amount_due ?? newest.total ?? 0,
currency: newest.currency ?? 'usd',
attemptCount: newest.attempt_count ?? 0,
nextPaymentAttempt: newest.next_payment_attempt
? new Date(newest.next_payment_attempt * 1000)
: null,
};
}