Skip to main content

Billing Foundation

Status note

This is the first-pass billing foundation for GitHub issue #58.

It establishes internal Convex state for Stripe-backed billing without making live Stripe calls, adding the Stripe SDK, creating provider resources, or locking paid plan packaging.

Locked decision

  • product code should read internal entitlement snapshots, not Stripe directly
  • Stripe customer ids, subscription ids, price ids, and event ids are internal backend state
  • webhook processing must be idempotent and preserve event provenance when it is implemented
  • actual Stripe webhook handling, Checkout/customer creation calls, and billing portal session calls remain follow-up work

Current implementation

  • convex/schema.ts defines billingCustomerMappings, billingSubscriptionSnapshots, and billingEntitlementSnapshots
  • convex/_billing.ts defines shared validators plus pure status helpers for subscription and entitlement state
  • tests/backend/billing-foundation.test.ts covers status normalization and the subscription-to-entitlement derivation rule

billingCustomerMappings maps an internal owner to a Stripe customer. The owner can be an app user or a profile. Community-owned billing should prefer the community profile owner path once the product flow exists.

billingSubscriptionSnapshots stores the current internal projection of a Stripe subscription. It records Stripe ids, normalized status, current period timestamps, cancellation timestamps, and last Stripe event provenance.

billingEntitlementSnapshots stores the product-facing entitlement state. This is the table product features should query once paid features exist.

Expected environment variables

No current runtime code reads these variables yet. These names are the committed bootstrap contract for the follow-up work that implements Stripe calls.

Server-only Convex or backend environment:

  • STRIPE_SECRET_KEY: Stripe secret API key for the active deployment environment. Store only in Convex, Vercel, or another approved secret store.
  • STRIPE_WEBHOOK_SECRET: Stripe webhook endpoint signing secret. Store only in the backend secret store for the deployment that receives the webhook.
  • STRIPE_API_VERSION: optional non-secret version pin once SDK calls are added. Prefer a committed code-level constant if the SDK path makes that simpler.
  • VRDEX_STRIPE_PORTAL_RETURN_URL: non-secret URL where Stripe billing portal sessions should return the user.
  • VRDEX_STRIPE_CHECKOUT_SUCCESS_URL: non-secret Checkout success URL when Checkout-based customer creation is implemented.
  • VRDEX_STRIPE_CHECKOUT_CANCEL_URL: non-secret Checkout cancel URL when Checkout-based customer creation is implemented.

Not needed in this pass:

  • NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: defer until a billing UI or browser-side Stripe.js flow exists.
  • plan or price environment variables: defer until paid plan packaging is decided. Prefer Stripe lookup keys or committed non-secret catalog config over ad hoc dashboard-only price ids.

Secret owner and rotation rule:

  • the deployment operator owns the Stripe secret values
  • rotate STRIPE_SECRET_KEY from the Stripe dashboard or Stripe CLI, then update the backend secret store before enabling code that uses the new key
  • rotate STRIPE_WEBHOOK_SECRET by rolling the webhook endpoint secret, updating the backend secret store, and replaying a harmless test event before removing the old secret path
  • never commit secret values, webhook signing secrets, customer ids tied to real users, or live subscription ids in examples or fixtures

Customer Creation Direction

Current recommendation:

  • customer creation should be initiated by an authenticated backend action once a user has a verified email and has selected the owner being billed
  • the action should first look for an active billingCustomerMappings row for the internal owner and Stripe environment
  • if no mapping exists, the action may create a Stripe customer and persist the returned customer id in billingCustomerMappings
  • all later Checkout, portal, and webhook flows should resolve through the internal mapping before updating subscription or entitlement state

Follow-up boundary:

  • this first pass does not create Stripe customers or expose a customer creation mutation
  • safe no-secret stubs can be added later if a UI or integration test needs a deterministic shape before live Stripe calls exist

Webhook Direction

Current recommendation:

  • use a Convex HTTP action such as /stripe/webhook
  • verify the Stripe signature with STRIPE_WEBHOOK_SECRET before parsing or trusting the payload
  • handle subscription lifecycle events by upserting billingSubscriptionSnapshots and recalculating billingEntitlementSnapshots
  • persist lastStripeEventId and lastStripeEventCreatedAt on updated rows so replay handling and debugging have durable provenance
  • treat unknown or unsupported subscription statuses as unknown and derive inactive entitlements by default

Minimum useful event coverage for the follow-up:

  • checkout.session.completed
  • customer.subscription.created
  • customer.subscription.updated
  • customer.subscription.deleted
  • customer.deleted

Follow-up boundary:

  • this first pass does not expose a Stripe webhook route
  • this first pass does not verify signatures or parse Stripe payloads
  • a future webhook implementation should add idempotency tests before accepting provider traffic

Billing Portal Direction

Current recommendation:

  • portal session creation should be an authenticated backend action
  • the action should resolve the active billingCustomerMappings row for the selected owner
  • the action should create a short-lived Stripe billing portal session and return the Stripe-hosted URL to the web app
  • the app should not store portal session URLs as durable state

Follow-up boundary:

  • this first pass does not create portal sessions
  • this first pass does not add billing UI or paid feature gating

Entitlement Status Rule

deriveBillingEntitlementStatus currently maps Stripe subscription snapshots to product-facing entitlement status this way:

  • active -> active while the current period has not expired
  • trialing -> trialing while the current period has not expired
  • past_due -> grace_period while the current period has not expired
  • incomplete -> pending
  • canceled, incomplete_expired, paused, unpaid, and unknown -> inactive

This is intentionally conservative. Product features can later decide which entitlements treat trialing or grace_period as usable, but unknown or stale state should not unlock paid behavior by default.

Verification

Run the narrow billing helper tests after changing status behavior:

pnpm exec node --import tsx --test tests/backend/billing-foundation.test.ts

For schema changes, run the backend typecheck when dependencies are installed:

pnpm typecheck:backend