# Test-Only Billing Scaffold

This is not a production subscription system. No prices have been chosen, no
identity/entitlement backend has been selected, and no paid access is granted.
The implementation performs no work on import. Verification uses synthetic
fixtures and mocked provider/auth/store dependencies, not Stripe network calls.
No Stripe products, prices, subscriptions, or secrets were created. The app's
preview deployment includes the disabled setup surface and fail-closed routes;
that does not activate billing.

## Public Plans

`billing-plans.json` is the public plan contract. All published `amount` values
are `null`, even if test price mappings exist on a server. Currency is SGD and
the intended subscription interval is monthly.

| ID | Name | Included scope |
| --- | --- | --- |
| `creator` | Creator | Solo asset editing |
| `studio` | Studio | Solo asset editing, multiple project boards, batch workflows |
| `director` | Director | Studio scopes, filmmaking shot lists, Codex artifact handoff |

These are proposed scope groupings, not claims that billing enforces access or
that a paid feature has shipped. There are no teamwork, seat, or unlimited AI
promises. AI usage, quotas, pricing amounts, taxes, and production terms remain
undecided. No free trial is configured.

## Frontend Boundary

`billing.js` is a browser ESM module exporting `mountBilling(root)`. It immediately
renders three cards, uses the existing `--so-*` design tokens where available,
stacks on narrow screens, and displays **To be configured**, **SGD / month**, and
**No live charges**. Its sole request is a same-origin, no-store GET to
`/api/checkout`. It displays neither arbitrary response text nor price/secret
fields, never sends an access token, and never writes browser storage.

Checkout buttons intentionally stay disabled even if a GET reports a configured,
authenticated test backend: this is a read-only scaffold, not a browser checkout
integration. There is no POST, custom checkout event, payment-link fallback,
redirect, success-query unlock, or frontend entitlement state.

The landing owner can mount it at `#soPricing` from their ESM entrypoint:

```js
import { mountBilling } from './billing.js';
mountBilling(document.getElementById('soPricing'));
```

The landing now mounts this module at `#soPricing`. It does not import or route
to the pre-existing live payment link. The local development server serves the
read-only GET with an empty test configuration and rejects all local checkout
POSTs. The hosted endpoints remain fail-closed until their separate backend
requirements are configured. No live products or prices were created.

## Server Configuration

No credentials are embedded or provisioned. When separately authorized, supply
server-only variables through approved secret management, never public bundles,
query strings, logs, committed env files, or frontend storage.

| Variable | Requirement |
| --- | --- |
| `STRIPE_SECRET_KEY` | `sk_test_` secret only; live and restricted-key prefixes are rejected |
| `STRIPE_PRICE_CREATOR` | Creator's distinct allowlisted `price_` identifier |
| `STRIPE_PRICE_STUDIO` | Studio's distinct allowlisted `price_` identifier |
| `STRIPE_PRICE_DIRECTOR` | Director's distinct allowlisted `price_` identifier |
| `APP_ORIGIN` | Exact `http://127.0.0.1:8123` or `http://localhost:8123` |
| `BILLING_TEST_ACCESS_TOKEN` | Explicit server-only operator test token: 32-256 base64url characters |
| `STRIPE_WEBHOOK_SECRET` | Signing secret belonging to the test-mode webhook endpoint |

The origin allowlist is fixed in `billing-core.cjs`, limited to the observed
local development port. No deployment origin has been approved. Paths, trailing
slashes, arbitrary origins, request Host/forwarded-host values, and client return
URLs cannot expand it. Adding a deployed test origin requires an explicit review.

Price IDs have no test/live marker. Local validation checks format, mapping, and
uniqueness only; it does not prove that a price exists, is active, or is monthly
SGD. The operator must separately choose/verify recurring SGD/month **test**
prices before an authorized test. The session requests SGD and subscription
mode, and only a test key can make the provider call. No amount is inferred or
published from the mapped ID. `whsec_` similarly does not identify mode: choose
the test endpoint secret; signed events must explicitly have `livemode: false`.

`NODE_ENV=test`, preview deployments, trial flags, and request-supplied user
objects do not enable authentication or entitlements.

## Checkout Contract

`GET /api/checkout` never contacts Stripe. It returns `mode: "test"`, the public
plans, currency/interval, `configured`, `status`, sanitized issue codes,
`authentication: { configured, available }`, and `paidEntitlements: false`.
It exposes no mapped prices, key, access token, user ID, or derived subject.
`configured` means local configuration and a server-side auth mechanism pass
structural checks, not a verified Stripe account or a working entitlement system.
`available` means **this request** was authenticated. Status is `setup_required`,
`authentication_required`, or `test_ready`; none means production-ready.

`POST /api/checkout` accepts exactly this JSON body:

```json
{ "plan": "creator" }
```

It requires `Content-Type: application/json` and `Idempotency-Key` containing
16-128 ASCII letters, digits, underscores, or hyphens. The body limit is 4 KiB.
Query-string checkout parameters are rejected. Unknown plans and any additional JSON fields are rejected, including client
prices, URLs, metadata, identity, quantity, trial settings, and mode. There is no
anonymous checkout. Choose one backend authentication path:

- An explicitly configured operator token, supplied as `Authorization: Bearer
  <operator-token>` by an authorized server/CLI test caller. Comparison is
  timing-safe. This shared credential is **not** a customer identity.
- An injected `authenticateTestUser(req)` that validates the backend session and
  returns `{ id, authenticated: true, testOnly: true }`, or `null` to deny. IDs
  must be opaque ASCII letters/digits/underscore/hyphen, 1-128 characters. The
  adapter takes precedence over the token and must not trust client user fields.

The shipping route has no identity adapter. Without the explicit operator token,
it fails closed. Adapter exceptions fail closed with a sanitized 503. Cookie-based
test callers must send the exact allowed Origin; adapters must enforce their own
session validation, authorization and CSRF defenses. Token-only CLI calls can omit
Origin; if supplied, it must match `APP_ORIGIN`. No CORS allowance is emitted.

The outgoing request is a fixed `POST https://api.stripe.com/v1/checkout/sessions`
using built-in fetch, a ten-second timeout, and redirects disabled. It always sets
`mode=subscription`, SGD, one server-mapped price, and quantity one. Success/cancel
targets are fixed `APP_ORIGIN/?billing=test-success#soPricing` and
`APP_ORIGIN/?billing=test-cancel#soPricing`. Neither target proves payment.

Metadata is limited to `billing_mode`, `plan_id`, and a bounded hashed
`test_subject`, on both the Checkout Session and subscription. No token, email,
raw user ID or client metadata is forwarded. Stripe idempotency is a bounded hash
of the server subject, tier, mapped price, origin and caller nonce. Reuse a nonce
only for retries of the same checkout; a new subject/plan/configuration generates
a different key. Stripe's idempotency retention is not a permanent deduplication
database and does not prevent a tester starting another test subscription later.

Only a response explicitly marked `livemode: false`, `mode: subscription`, with
a `cs_test_` ID and its exact `https://checkout.stripe.com/c/pay/<session-id>` URL
is returned. Credentials, alternate hosts, query strings, mismatched sessions,
payment links and live responses are rejected. Stripe errors are sanitized.

## Webhook And Store Boundary

The shipping `api/stripe-webhook.js` is deliberately blocked with 503 until an
audited durable store is explicitly injected. Setting env vars alone cannot open
it. `createWebhookHandler({ env, entitlementStore, now })` is the testable core.

The handler accepts POST JSON only, up to 256 KiB, including streamed bodies. It
uses `req.rawBody`, a raw Buffer/string `req.body`, or an unread request stream.
It will **not** reserialize a parsed JSON body to verify it. The route exports
`config.api.bodyParser = false`; the hosting integration must actually honor this
or provide the original bytes. Verify this on the intended runtime before use.
Compressed bodies are rejected. Parsed-only bodies fail closed.

HMAC-SHA256 is computed over the exact `timestamp.rawBody` bytes. `v1` digests
use `crypto.timingSafeEqual`, support signature rotation, and require exactly one
timestamp within 300 seconds past or future. Signature headers and digest counts
are bounded. JSON parsing and store invocation happen **after** verification.
The timestamp check concerns delivery signing time, not event creation time.

Only signed, explicitly test-mode events are accepted. Supported app observations
are `checkout.session.completed` and `customer.subscription.created`, `.updated`,
and `.deleted` carrying `metadata.billing_mode=test`, a known `plan_id`, and the
server-shaped test subject. Live events, invalid app observations and unknown app
plans are rejected. Other test event types or untagged objects are acknowledged as
ignored, with no store call. In particular, `invoice.paid` is not implemented.

The injected store must implement:

```js
const entitlementStore = {
  durable: true,
  async recordVerifiedEvent({ idempotencyKey, verified, mode, event }) {
    // Transactionally insert a unique test event receipt and its observation.
    // Return only after commit; duplicates must be a no-op.
    // Return { duplicate: false } for a new receipt, { duplicate: true } on replay.
  }
};
```

`durable: true` is an explicit adapter contract, **not proof of durability**.
Do not ship the tests' in-memory mock as this adapter. Use a real database with a
unique constraint on `idempotencyKey` (`stripe:test:<event-id>`) and atomic receipt
plus observation persistence. There is intentionally no separate check-then-write
API. Exceptions and malformed receipts return 503 so Stripe can retry; a replay
returns success only after the durable adapter confirms it is a duplicate.

The store receives only bounded `id`, `type`, `created`, `livemode: false`,
`objectId`, `planId`, and `testSubject` observations, plus the verified/test flags.
It does not receive raw customer details, event payloads, or secrets. A successful
response means **verified and recorded**, never paid/unlocked. This scaffold does
not interpret payment status or implement entitlement transitions, billing-period
expiry, cancellation, refund, invoice reconciliation, event ordering, or identity
binding. Signed metadata and checkout completion alone cannot authorize access.

## Verification And Remaining Gate

Run the dedicated tests without changing package scripts:

```sh
node --test tests/billing.test.cjs
BILLING_BROWSER_TEST=1 node --test tests/billing.test.cjs
```

The second command uses the installed Playwright and this project's existing
Chrome-path convention (`CHROME_PATH`, otherwise Google Chrome on macOS or
Playwright's installed Chromium elsewhere). It does not install a browser.
Browser HTTP is fully intercepted. It checks desktop
and mobile layout, all disabled setup states, no token/price display, GET-only
behavior, no storage writes and no JavaScript errors. The core tests inject mocked
Stripe/auth/store dependencies, prohibit default fetch, and prove input rejection,
raw signatures, replay handling, store failures and route gates.

Narrow blocker: a reviewed identity model and durable entitlement database/adapter
are still unchosen. Before a production billing implementation, separately decide
amounts and limits, verify the recurring prices, design authorization and lifecycle
reconciliation, enforce database uniqueness, add rate limiting/session/CSRF controls,
verify raw-body hosting behavior and register an approved origin. Then authorize a
distinct production implementation and test/release process. This test-only code
rejects live keys/events and must not be presented as production billing.
