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
+2
View File
@@ -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
View File
@@ -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
+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;
+1 -1
View File
@@ -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) {
+3 -1
View File
@@ -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>