diff --git a/CHANGELOG.md b/CHANGELOG.md index 08222e9..c1cb7a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Reorganized the pricing rollout tracker to reflect completed phases, deferred work, and the new app-admin and workspace-role migration milestones. - Updated auth/session responses to include canonical admin-status checks so admin UI state stays consistent after refresh and login. - Updated README and TODO planning docs for phased admin-console rollout and the first-run operational checklist. +- Hardened pending-downgrade lifecycle handling so Stripe-scheduled downgrades are preserved in billing state and apply automatically when the effective date is reached. +- Clarified post-downgrade quota messaging in enforcement, account UI, and admin billing detail so over-limit behavior is explicit after scheduled plan changes. ## [2026-05-22] diff --git a/TODO-pricing.md b/TODO-pricing.md index 42ddd76..5fdc970 100644 --- a/TODO-pricing.md +++ b/TODO-pricing.md @@ -82,7 +82,7 @@ - [x] Add migration path for current internal admin access: keep temporary env fallback only during rollout, then remove once DB-seeded admins are verified. - [x] Centralize admin authorization middleware (`requireAdmin`) and replace route-local billing-admin checks so `/admin/*` authorization semantics are consistent. - [x] Add admin audit visibility: log admin route access and key admin support actions with actor, route/action, target workspace, and timestamp. -- [ ] Define explicit downgrade behavior: +- [x] Define explicit downgrade behavior: - effective timing for scheduled vs immediate plan changes - entitlement/usage treatment when the target plan is below current usage - account messaging for pending downgrade state diff --git a/server/src/billing/enforcement-service.ts b/server/src/billing/enforcement-service.ts index f3a9e43..5bf7410 100644 --- a/server/src/billing/enforcement-service.ts +++ b/server/src/billing/enforcement-service.ts @@ -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.'; } diff --git a/server/src/billing/service.ts b/server/src/billing/service.ts index f945817..a30e921 100644 --- a/server/src/billing/service.ts +++ b/server/src/billing/service.ts @@ -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 { 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<{ diff --git a/server/src/payments/service.ts b/server/src/payments/service.ts index e573f3c..85ba000 100644 --- a/server/src/payments/service.ts +++ b/server/src/payments/service.ts @@ -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; diff --git a/shared/billing/lifecycle.ts b/shared/billing/lifecycle.ts index 746d174..e337c84 100644 --- a/shared/billing/lifecycle.ts +++ b/shared/billing/lifecycle.ts @@ -114,7 +114,7 @@ export function getPendingPlanChangeMessage(pendingPlanCode: PlanCode | null, pe return null; } - return `Plan change to ${pendingPlanCode} takes effect on ${formatLifecycleDate(pendingPlanEffectiveAt)}.`; + return `A plan change to ${pendingPlanCode} is scheduled for ${formatLifecycleDate(pendingPlanEffectiveAt)}. If usage is above the new limits after that date, chargeable actions may pause until usage resets or additional capacity is added.`; } export function formatLifecycleDate(value: string | null) { diff --git a/src/components/AccountPage.tsx b/src/components/AccountPage.tsx index f31b4ca..fab51d0 100644 --- a/src/components/AccountPage.tsx +++ b/src/components/AccountPage.tsx @@ -417,7 +417,7 @@ export function AccountPage({ user, onUserUpdated, initialCheckoutPlanCode = nul

{account.billing.message}

{account.billing.pendingPlanCode && account.billing.pendingPlanEffectiveAt ? (
- Pending change to {account.billing.pendingPlanCode} on {formatLifecycleDate(account.billing.pendingPlanEffectiveAt)}. + Scheduled downgrade to {account.billing.pendingPlanCode} on {formatLifecycleDate(account.billing.pendingPlanEffectiveAt)}. If usage is above the new limits after this date, chargeable actions may pause until usage resets, you add capacity, or you upgrade again.
) : null}
@@ -679,6 +679,8 @@ export function AccountPage({ user, onUserUpdated, initialCheckoutPlanCode = nul
Renewal: {formatDateLabel(adminWorkspaceDetail.billing.currentPeriodEndsAt)}
Grace ends: {formatDateLabel(adminWorkspaceDetail.billing.gracePeriodEndsAt)}
+
Pending plan: {adminWorkspaceDetail.billing.pendingPlanCode || 'None'}
+
Pending effective: {formatDateLabel(adminWorkspaceDetail.billing.pendingPlanEffectiveAt)}
Subscription ref: {adminWorkspaceDetail.summary.externalSubscriptionRef || 'Not set'}
Usage period id: {adminWorkspaceDetail.usagePeriodId || 'Not active'}