mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
1386 lines
50 KiB
TypeScript
1386 lines
50 KiB
TypeScript
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 else’s 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,
|
||
};
|
||
}
|