§ unswayed-backend · Subscriptions & billing (Phase 10)
Checkout URL fallback pages
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:
requestOrigin(req)(src/common/helpers/request-origin.ts) returns the request's public origin —`${req.protocol}://${req.host}`. It's the sibling ofrequestPath(which also appends the path).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.The controller injects
@Req(), computes the origin, and passes it intoSubscriptionsService.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.