SsuperslateDocs
Build with the boilerplate

Add a billing-gated feature

Gate a feature on server-derived entitlement instead of browser-only checks.

Golden path: add a billing-gated feature

When to use

Use this path when an authenticated product capability must require either an active application trial or a paid entitlement. First decide whether the entire domain, a mutation, or only an expensive operation is paid. Do not gate authentication, account recovery, billing status, checkout/portal recovery, health, or provider webhooks.

The server is authoritative. A hidden button, private React route, or cached user.is_premium flag alone is not a billing gate.

Files and boundaries

  • apps/server/src/domains/billing/entitlements.ts: the only subscription-status-to-access policy.
  • apps/server/src/domains/billing/service.ts: combines paid entitlement with the application trial.
  • apps/server/src/infra/http/middlewares/trial.ts: reusable API enforcement after sessionAuth.
  • apps/server/src/domains/<domain>/routes.ts: mounts authentication and entitlement middleware.
  • domain service/repository code: product behavior; it must not import a provider SDK or trust a client plan.
  • apps/web/src/hooks/use-trial.ts and protected UI: explanation and navigation, not authority.
  • HTTP/integration tests: allowed trial/paid access plus denied/repair behavior.

Provider SDK types stay in packages/billing. Application domains depend on an entitlement result, never a provider customer, product, or webhook object.

Procedure

  1. State the paid job and the denial boundary. Decide whether reads, writes, exports, compute, or the whole domain are gated. Keep enough account and billing UI reachable to recover payment.

  2. Reuse the current policy unless the commercial decision itself is changing:

    • active and provider trialing grant paid access;
    • canceled subscriptions grant access strictly before current_period_end;
    • past_due, unpaid, paused, incomplete, and expired cancellations do not;
    • the separate application trial may still grant access.
  3. For a whole domain, mount middleware in this order:

    new Hono<AppEnv>().use(sessionAuth).use(requireActiveTrialOrPremium)

    For one operation, run the same server-side access check immediately after authentication and before expensive work or mutation. Do not introduce a second status list.

  4. Leave /billing/status, checkout, portal recovery, auth, health, and signed webhooks outside the paid gate. A past-due subscription must reach the portal and must not start a duplicate checkout.

  5. In the web app, use the billing-status query to explain the state, disable pending actions, and route denied users to billing. Treat this as usability and defense-in-depth.

  6. Add tests proving:

    • an authenticated active application trial succeeds;
    • an expired trial without paid entitlement receives 403;
    • active/provider-trial and canceled-with-period access succeed;
    • past-due and expired cancellation fail;
    • unauthenticated requests still receive the authentication contract;
    • the billing recovery route remains usable.
  7. Run the change in a custom generated buyer and record any manual clarification the agent needed.

Verification

vp check
vp run --filter @app/server typecheck
bun test --cwd apps/server src/domains/billing/entitlements.test.ts
RUN_DB_INTEGRATION_TESTS=1 \
  DATABASE_URL='postgres://localhost:5432/app_billing_gate_proof?sslmode=disable' \
  bun test --cwd apps/server src/infra/http/middlewares/trial.integration.test.ts
vp run -r test
vp run -r build
git diff --check

Also call the protected API directly rather than relying on the browser. Verify the denial happens before a write, upload URL, paid provider call, or expensive computation.

Security constraints

  • Resolve user identity only from the Better Auth session.
  • Resolve entitlement only from server-owned subscription and user records.
  • Never accept is_premium, plan, status, expiry, owner, or trial days from request input.
  • Keep billing recovery available to denied users.
  • Deny before side effects and keep the denial response free of provider/customer secrets.
  • Do not cache a grant beyond a lifecycle update unless expiry and invalidation are proven.

Failure modes

  • The UI redirects but direct API calls still succeed.
  • Middleware is mounted before session authentication and sees no user.
  • A copied status array grants past_due while the central policy denies it.
  • The whole API is gated, preventing payment recovery or logout.
  • A route performs storage/provider work before checking access.
  • users.is_premium drifts and becomes the only authority.
  • A canceled customer loses prepaid access early or keeps it at the exact period boundary.

Rollback and diagnosis

If an entitled user is denied, inspect the local subscription, provider_modified_at, current_period_end, and the application-trial end before changing policy. Replay the signed provider event only after checking the event ledger. If direct API access succeeds while the UI denies it, add/fix the server middleware; do not weaken the web guard.

To remove a gate, delete the middleware at the intended domain/operation and its product-specific copy, but retain auth, ownership, validation, rate limits, and the central billing tests.

Acceptance criteria

  • The paid job and recovery paths are explicit.
  • The API denies expired/unentitled access before side effects.
  • The browser communicates the same state without being the authority.
  • Every lifecycle status and cancellation boundary follows Decision 0003.
  • Authenticated, unauthenticated, entitled, expired, past-due, and recovery cases are tested.
  • Root checks and a generated-buyer execution pass with interventions recorded.

Agent prohibitions

  • Do not authorize billing in React alone.
  • Do not trust client billing claims or provider payloads outside the signed webhook adapter.
  • Do not duplicate the entitlement status table in a domain.
  • Do not gate auth, billing recovery, health, or webhook endpoints.
  • Do not add an implicit past-due grace period.
  • Do not call the path complete without a direct API denial test.

On this page