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 aftersessionAuth.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.tsand 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
-
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.
-
Reuse the current policy unless the commercial decision itself is changing:
activeand providertrialinggrant 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.
-
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.
-
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. -
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.
-
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.
-
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 --checkAlso 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_duewhile 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_premiumdrifts 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.