CODEX // oaoisme wiki

§ unswayed-backend · Subscriptions & billing (Phase 10)

Checkout URL fallback pages

updated 2026-06-15

The problem

POST /api/subscriptions/checkout builds a Stripe hosted Checkout session. Stripe needs an absolute success_url to send the browser back to after payment (and an optional cancel_url for "go back"). Originally the API required the client to supply both, validated required|url.

That's fine for a web app that owns a https://…/billing/success page. But a mobile or headless caller has no such web page — yet it still needs to open Stripe Checkout. It had nothing valid to send.

The fix (ADR-0038)

Both URLs are now optional. When the client omits one, the server points Stripe at its own HTML landing page, served publicly by this backend:

GET /api/subscriptions/checkout/success   → "Payment successful" page
GET /api/subscriptions/checkout/cancel    → "Checkout canceled" page

So a caller can start checkout with just { "plan_id": 2 }.

How a URL is resolved

Three small, independent pieces:

  1. requestOrigin(req) (src/common/helpers/request-origin.ts) returns the request's public origin — `${req.protocol}://${req.host}`. It's the sibling of requestPath (which also appends the path).

  2. resolveCheckoutUrls(dto, origin) (src/subscriptions/checkout-urls.ts) is a pure function: it returns the client's URL when present, otherwise `${origin}/api/subscriptions/checkout/{success,cancel}`. No request, no Stripe — just string selection, trivially unit-tested.

  3. The controller injects @Req(), computes the origin, and passes it into SubscriptionsService.checkout(userId, dto, origin). Request-coupling stays in the controller; the service owns the business rule.

controller (has req) → requestOrigin(req) → service.checkout(…, origin)
                                              → resolveCheckoutUrls(dto, origin)
                                              → billing.createCheckoutSession(…, successUrl, cancelUrl)

Why trust proxy matters

req.protocol is http by default — because nginx talks to Node over plain HTTP on 127.0.0.1. If we used that, the fallback URL would be http://… even though the public site is HTTPS.

nginx already forwards X-Forwarded-Proto $scheme and Host $host. So configureApp enables Express trust proxy: 'loopback', which tells Express to honour those headers. Now req.protocol is the real https and req.host is the public host. (It's loopback-only because the app binds 127.0.0.1 and nginx is the sole ingress — nothing else can spoof the headers.) This also fixes every other request-derived URL, e.g. the paginator's meta.path.

The pages themselves

checkout-page.ts exports checkoutSuccessPage() / checkoutCancelPage() — static, inline-styled, script-free HTML, the same idiom as the Google Calendar / LinkedIn OAuth callback pages (callback-page.ts).

They are served by StripeCheckoutWebController, which is public — it carries no guards. That's deliberate: Stripe's browser redirect arrives with no access token, so the pages can't sit on the JWT-guarded SubscriptionsController. They read no database and show nothing user-specific, so there is nothing to authorise.

Contract note

The read-only endpoint_documentation.md §44 still lists both URLs as required|url. Per the owner's rule that file is never edited; this is a deliberate, documented divergence (the server is a superset — a missing URL used to be a 422 and is now accepted). The divergence is captured in ADR-0038, docs/API-CONTRACT.md, and docs/FEATURES.md.