feat: harden scheduled downgrade lifecycle and messaging
This commit is contained in:
@@ -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]
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -417,7 +417,7 @@ export function AccountPage({ user, onUserUpdated, initialCheckoutPlanCode = nul
|
||||
<p className="mt-4 text-sm text-stone-600">{account.billing.message}</p>
|
||||
{account.billing.pendingPlanCode && account.billing.pendingPlanEffectiveAt ? (
|
||||
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
Pending change to <span className="font-semibold">{account.billing.pendingPlanCode}</span> on {formatLifecycleDate(account.billing.pendingPlanEffectiveAt)}.
|
||||
Scheduled downgrade to <span className="font-semibold">{account.billing.pendingPlanCode}</span> 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.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
@@ -679,6 +679,8 @@ export function AccountPage({ user, onUserUpdated, initialCheckoutPlanCode = nul
|
||||
<div className="grid gap-2 text-stone-600">
|
||||
<div>Renewal: <span className="font-medium text-stone-900">{formatDateLabel(adminWorkspaceDetail.billing.currentPeriodEndsAt)}</span></div>
|
||||
<div>Grace ends: <span className="font-medium text-stone-900">{formatDateLabel(adminWorkspaceDetail.billing.gracePeriodEndsAt)}</span></div>
|
||||
<div>Pending plan: <span className="font-medium text-stone-900">{adminWorkspaceDetail.billing.pendingPlanCode || 'None'}</span></div>
|
||||
<div>Pending effective: <span className="font-medium text-stone-900">{formatDateLabel(adminWorkspaceDetail.billing.pendingPlanEffectiveAt)}</span></div>
|
||||
<div>Subscription ref: <span className="font-medium text-stone-900">{adminWorkspaceDetail.summary.externalSubscriptionRef || 'Not set'}</span></div>
|
||||
<div>Usage period id: <span className="font-medium text-stone-900">{adminWorkspaceDetail.usagePeriodId || 'Not active'}</span></div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user