Public Access
1
0

feat: harden scheduled downgrade lifecycle and messaging

This commit is contained in:
pguerrerox
2026-05-26 00:34:00 +00:00
parent 232342d6a1
commit f1c3e2db7d
7 changed files with 133 additions and 35 deletions
+1 -1
View File
@@ -204,7 +204,7 @@ function formatEntitlementErrorMessage(decision: EntitlementDecision) {
case 'custom_enterprise_only':
return 'This action requires an enterprise or custom sales engagement.';
case 'quota_exhausted':
return 'Your current plan has exhausted the available allowance for this action.';
return 'Your current plan allowance for this action is exhausted. If a scheduled downgrade just took effect, chargeable actions can pause until usage resets, add-ons are purchased, or the plan is upgraded.';
default:
return 'This action is blocked by the current entitlement policy.';
}
+67 -29
View File
@@ -1,6 +1,7 @@
import type { Pool, PoolClient } from 'pg';
import { getUsageAllowanceForPlan, isActivePlanCodeForEntitlements } from '../../../shared/billing/entitlements.js';
import { getPendingPlanChangeMessage, isBillingSyncStale, resolveBillingAccessState } from '../../../shared/billing/lifecycle.js';
import { getPlanByCode } from '../../../shared/billing/plans.js';
import type { BillingAddonBalanceSummary, BillingUsageResourceSummary, AccountBillingState } from '../../../shared/types.js';
import type { UsageResource } from '../../../shared/billing/entitlements.js';
import {
@@ -10,61 +11,98 @@ import {
listUsageCountersForPeriod,
type BillingAccountRecord,
type UsagePeriodRecord,
updateBillingAccountState,
} from './repository.js';
type DbClient = Pool | PoolClient;
export async function getWorkspaceBillingState(db: DbClient, workspaceId: string): Promise<AccountBillingState> {
const billingAccount = await ensureBillingAccountForWorkspace(db, workspaceId);
const canonicalBillingAccount = await applyPendingPlanIfEffective(db, billingAccount);
if (!billingAccount.planCode || !isActivePlanCodeForEntitlements(billingAccount.planCode)) {
if (!canonicalBillingAccount.planCode || !isActivePlanCodeForEntitlements(canonicalBillingAccount.planCode)) {
return {
status: billingAccount.status,
planCode: billingAccount.planCode,
billingInterval: billingAccount.billingInterval,
currentPeriodStartsAt: billingAccount.currentPeriodStartsAt,
currentPeriodEndsAt: billingAccount.currentPeriodEndsAt,
cancelAtPeriodEnd: billingAccount.cancelAtPeriodEnd,
canceledAt: billingAccount.canceledAt,
trialEndsAt: billingAccount.trialEndsAt,
gracePeriodEndsAt: billingAccount.gracePeriodEndsAt,
pendingPlanCode: billingAccount.pendingPlanCode,
pendingPlanEffectiveAt: billingAccount.pendingPlanEffectiveAt,
billingSyncStatus: billingAccount.billingSyncStatus,
lastStripeSyncAt: billingAccount.lastStripeSyncAt,
provider: billingAccount.externalCustomerRef ? 'stripe' : null,
externalCustomerRef: billingAccount.externalCustomerRef,
externalSubscriptionRef: billingAccount.externalSubscriptionRef,
status: canonicalBillingAccount.status,
planCode: canonicalBillingAccount.planCode,
billingInterval: canonicalBillingAccount.billingInterval,
currentPeriodStartsAt: canonicalBillingAccount.currentPeriodStartsAt,
currentPeriodEndsAt: canonicalBillingAccount.currentPeriodEndsAt,
cancelAtPeriodEnd: canonicalBillingAccount.cancelAtPeriodEnd,
canceledAt: canonicalBillingAccount.canceledAt,
trialEndsAt: canonicalBillingAccount.trialEndsAt,
gracePeriodEndsAt: canonicalBillingAccount.gracePeriodEndsAt,
pendingPlanCode: canonicalBillingAccount.pendingPlanCode,
pendingPlanEffectiveAt: canonicalBillingAccount.pendingPlanEffectiveAt,
billingSyncStatus: canonicalBillingAccount.billingSyncStatus,
lastStripeSyncAt: canonicalBillingAccount.lastStripeSyncAt,
provider: canonicalBillingAccount.externalCustomerRef ? 'stripe' : null,
externalCustomerRef: canonicalBillingAccount.externalCustomerRef,
externalSubscriptionRef: canonicalBillingAccount.externalSubscriptionRef,
usage: [],
addonBalances: await listAddonBalancesForWorkspace(db, workspaceId),
message: 'Subscription management is being prepared. Plan details, usage tracking, and billing controls will appear here in a future update.',
};
}
const usageSnapshot = await getWorkspaceUsageSnapshot(db, billingAccount);
const usageSnapshot = await getWorkspaceUsageSnapshot(db, canonicalBillingAccount);
const addonBalances = await listAddonBalancesForWorkspace(db, workspaceId);
return {
status: billingAccount.status,
planCode: billingAccount.planCode,
billingInterval: billingAccount.billingInterval,
status: canonicalBillingAccount.status,
planCode: canonicalBillingAccount.planCode,
billingInterval: canonicalBillingAccount.billingInterval,
currentPeriodStartsAt: usageSnapshot.currentPeriodStartsAt,
currentPeriodEndsAt: usageSnapshot.currentPeriodEndsAt,
cancelAtPeriodEnd: canonicalBillingAccount.cancelAtPeriodEnd,
canceledAt: canonicalBillingAccount.canceledAt,
trialEndsAt: canonicalBillingAccount.trialEndsAt,
gracePeriodEndsAt: canonicalBillingAccount.gracePeriodEndsAt,
pendingPlanCode: canonicalBillingAccount.pendingPlanCode,
pendingPlanEffectiveAt: canonicalBillingAccount.pendingPlanEffectiveAt,
billingSyncStatus: isBillingSyncStale(canonicalBillingAccount.lastStripeSyncAt) ? 'stale' : canonicalBillingAccount.billingSyncStatus,
lastStripeSyncAt: canonicalBillingAccount.lastStripeSyncAt,
provider: canonicalBillingAccount.externalCustomerRef ? 'stripe' : null,
externalCustomerRef: canonicalBillingAccount.externalCustomerRef,
externalSubscriptionRef: canonicalBillingAccount.externalSubscriptionRef,
usage: usageSnapshot.usage,
addonBalances,
message: buildBillingAccountMessage(canonicalBillingAccount),
};
}
async function applyPendingPlanIfEffective(db: DbClient, billingAccount: BillingAccountRecord, referenceDate = new Date()) {
if (!billingAccount.pendingPlanCode || !billingAccount.pendingPlanEffectiveAt) {
return billingAccount;
}
const effectiveAt = new Date(billingAccount.pendingPlanEffectiveAt);
if (Number.isNaN(effectiveAt.getTime()) || effectiveAt > referenceDate) {
return billingAccount;
}
const pendingPlan = getPlanByCode(billingAccount.pendingPlanCode);
if (!pendingPlan) {
return billingAccount;
}
return updateBillingAccountState(db, {
workspaceId: billingAccount.workspaceId,
planCode: pendingPlan.code,
billingInterval: pendingPlan.billingInterval,
status: billingAccount.status,
currentPeriodStartsAt: billingAccount.currentPeriodStartsAt,
currentPeriodEndsAt: billingAccount.currentPeriodEndsAt,
cancelAtPeriodEnd: billingAccount.cancelAtPeriodEnd,
canceledAt: billingAccount.canceledAt,
trialEndsAt: billingAccount.trialEndsAt,
gracePeriodEndsAt: billingAccount.gracePeriodEndsAt,
pendingPlanCode: billingAccount.pendingPlanCode,
pendingPlanEffectiveAt: billingAccount.pendingPlanEffectiveAt,
billingSyncStatus: isBillingSyncStale(billingAccount.lastStripeSyncAt) ? 'stale' : billingAccount.billingSyncStatus,
pendingPlanCode: null,
pendingPlanEffectiveAt: null,
billingSyncStatus: billingAccount.billingSyncStatus,
lastStripeSyncAt: billingAccount.lastStripeSyncAt,
provider: billingAccount.externalCustomerRef ? 'stripe' : null,
externalCustomerRef: billingAccount.externalCustomerRef,
externalSubscriptionRef: billingAccount.externalSubscriptionRef,
usage: usageSnapshot.usage,
addonBalances,
message: buildBillingAccountMessage(billingAccount),
};
});
}
export async function getWorkspaceUsageSnapshot(db: DbClient, billingAccount: BillingAccountRecord): Promise<{
+58 -2
View File
@@ -205,6 +205,7 @@ export async function syncWorkspaceBillingFromStripeSubscription(
const billingAccount = await ensureBillingAccountForWorkspace(db, workspaceId);
const plan = getPlanByCode(planCode);
const pendingDowngrade = resolveStripePendingDowngrade(subscription, planCode);
const nextStatus = mapStripeSubscriptionStatus(subscription.status);
@@ -225,8 +226,8 @@ export async function syncWorkspaceBillingFromStripeSubscription(
gracePeriodEndsAt: nextStatus === 'past_due'
? billingAccount.gracePeriodEndsAt ?? getDefaultBillingGracePeriodEndsAt(new Date())
: null,
pendingPlanCode: null,
pendingPlanEffectiveAt: null,
pendingPlanCode: pendingDowngrade?.planCode ?? null,
pendingPlanEffectiveAt: pendingDowngrade?.effectiveAt ?? null,
billingSyncStatus: 'ok',
lastStripeSyncAt: new Date().toISOString(),
externalCustomerRef: extractStripeCustomerId(subscription.customer) ?? billingAccount.externalCustomerRef,
@@ -236,6 +237,61 @@ export async function syncWorkspaceBillingFromStripeSubscription(
return workspaceId;
}
function resolveStripePendingDowngrade(subscription: Stripe.Subscription, currentPlanCode: ActivePlanCode) {
const pendingPriceId = subscription.pending_update?.subscription_items?.[0]?.price?.id;
if (!pendingPriceId) {
return null;
}
const pendingPlanCode = getPlanCodeForStripePriceId(getEnv(), pendingPriceId);
if (!pendingPlanCode) {
return null;
}
const isDowngrade = isPlanDowngrade(currentPlanCode, pendingPlanCode);
if (!isDowngrade) {
return null;
}
return {
planCode: pendingPlanCode,
effectiveAt: toIsoStringOrNull(subscription.items.data[0]?.current_period_end ?? null),
};
}
function isPlanDowngrade(currentPlanCode: ActivePlanCode, pendingPlanCode: ActivePlanCode) {
const currentPlan = getPlanByCode(currentPlanCode);
const pendingPlan = getPlanByCode(pendingPlanCode);
if (!currentPlan || !pendingPlan) {
return false;
}
const tierRank = {
starter: 0,
growth: 1,
pro: 2,
enterprise: 3,
} as const;
if (tierRank[pendingPlan.tier] < tierRank[currentPlan.tier]) {
return true;
}
if (tierRank[pendingPlan.tier] > tierRank[currentPlan.tier]) {
return false;
}
if (currentPlan.priceCents === null || pendingPlan.priceCents === null) {
return false;
}
return pendingPlan.priceCents < currentPlan.priceCents;
}
export async function fulfillAddonCheckoutSession(db: DbClient, session: Stripe.Checkout.Session) {
const workspaceId = session.client_reference_id || getMetadataValue(session.metadata, 'workspaceId');
const addonCode = getMetadataValue(session.metadata, 'addonCode') as AddonCode | null;