Add a billing plan
Add a plan while preserving provider normalization, entitlements, and webhook recovery.
Golden path: add a billing plan
When to use
Use this path when the product offer has a validated new billing interval or package that must be selectable at checkout. A coupon, temporary launch price, feature flag, usage meter, seat quantity, or provider-side price replacement is not automatically a new application plan.
Adding a plan changes a commercial contract, shared wire contract, environment contract, webhook mapping, checkout, and buyer UI. Do it only with an approved offer and provider product.
Files and boundaries
packages/contracts/src/billing.ts: canonicalPlanTypeSchema, checkout input, and public plan response.apps/server/src/domains/billing/types.ts: provider-neutral persisted/internal plan type.apps/server/src/config/env.tsand.env.example: product ID and display price validation.apps/server/src/domains/billing/service.ts: plan labels, product mapping, checkout, and public configs.apps/web/src/modules/settings/pages/billing/BillingSettings.tsx: description and interval-aware presentation.packages/billing/src/providers/<selected>.ts: SDK normalization only; no product offer decisions.- migration only when the database has a real enum/constraint that must change. The current plan column is text, so do not create a no-op migration.
- contracts, environment, service/integration, and UI tests.
Procedure
- Record offer name, buyer, interval, provider product ID owner, price display source, cancellation behavior, upgrade/downgrade behavior, and whether existing customers may select it.
- Create the product/price in the selected provider's sandbox/test mode. Never commit its token or webhook secret. Treat the product ID as deployment configuration even though it is not a credential.
- Add the plan once to
PlanTypeSchemaand derive/import the shared type where possible. Update the server internal union only where persistence requires it; do not create provider-named wire types. - Add one product-ID and display-price variable at the validated config boundary. Billing remains fully optional when every selected-provider value is unset. Once configured, all credentials and at least one product are required, prices are positive, and product/price IDs are unique.
- Extend
getProductIdForPlan, reverse product mapping, label, public config, and interval. A signed webhook with an unknown product or mismatchedplan_typemetadata must fail and roll back its event claim; never default it to another paid plan. - Update the billing UI's exhaustive plan description and price formatting. Render from
/billing/plans; do not embed a provider product ID or treat a displayed number as the amount charged. - Ensure checkout sends
user_id/external customer identity andplan_typemetadata. Keep provider payloads insidepackages/billing. - Test valid checkout input, invalid plan input, disabled billing, partial config, duplicate IDs, plan/product mismatch, and the new public plan response. Use the selected provider's sandbox/test mode for the final checkout-to-webhook smoke test.
- Update commercial copy and support policy only after the sandbox lifecycle succeeds.
Verification
vp check
vp run --filter @app/contracts test
vp run --filter @app/server typecheck
bun test --cwd apps/server src/config/env.test.ts src/domains/billing
vp run --filter @app/web typecheck
vp run -r test
vp run -r build
git diff --checkIn the selected provider's sandbox/test mode, verify checkout creation, redirect, one signed lifecycle webhook, local plan mapping, billing status, portal recovery, cancellation, and a replay of the same event ID. Verify a custom generated buyer with no selected-provider values still boots locally.
Security constraints
- Never put access tokens or webhook secrets in source, web variables, fixtures, command logs, or issue comments.
- Do not accept a product ID, price, status, or entitlement from checkout request JSON.
- Validate the requested plan with shared Zod and map it to server configuration.
- Require signed provider events and transactional idempotency before changing local state.
- Reject unknown or contradictory product mappings; silent fallback can grant the wrong product.
- Keep displayed prices clearly separate from provider-charged amounts.
Failure modes
- Contracts accept the plan but server mapping or UI exhaustive records omit it.
- Two plan names point to the same provider product.
- A partially configured deployment boots and fails only after a customer clicks checkout.
- The UI displays a local price that differs from the selected provider.
- An unknown webhook product silently becomes
monthly. - The plan is added directly to one provider adapter, coupling product policy to one SDK.
- Tests use a real production token or generate a billable purchase.
Rollback and diagnosis
Disable selection in application code before archiving a provider product. Existing customer webhooks still need a resolvable mapping; after launch, removal requires an explicit migration and support policy rather than deleting the environment variable. Inspect the event ledger, provider timestamp, metadata plan, and configured reverse mapping when diagnosis reports an unknown product.
Before the first commercial tag, a rejected offer may be removed cleanly because there are no live installations. After that tag, preserve valid historical plan values or migrate them deliberately.
Acceptance criteria
- The commercial offer and lifecycle behavior are approved.
- Shared input, server mapping, environment validation, webhook mapping, and UI are exhaustive.
- Disabled, partial, duplicate, unknown, mismatched, and successful configurations are tested.
- Selected-provider sandbox/test checkout through signed webhook and replay succeeds.
- Zero-key generated-buyer startup remains valid.
- Root verification and a generated-buyer execution pass with interventions recorded.
Agent prohibitions
- Do not invent a plan to increase feature count.
- Do not trust client-supplied price or product ID.
- Do not default an unknown provider product to an existing plan.
- Do not duplicate provider payload types in public contracts.
- Do not change or combine adapters as part of adding a plan.
- Do not claim completion from a rendered pricing card without checkout/webhook proof.