# Currency-localised pricing — Design Spec **Date:** 2026-07-29 **Status:** Draft — pending implementation plan ## Context `/pricing` hardcodes `£7` and `£70` in its copy and its buttons. Until commit `4169a67`, checkout sniffed `CF-IPCountry` / `Accept-Language` and passed a matching `currency` to Stripe, which then selected a `currency_options` rate off the Price. A US visitor was shown £7 and billed $9.99; a German visitor was billed €7. The page's "Prices in GBP" line was untrue for two of the three currencies. `4169a67` fixed that by forcing GBP for everyone — correct, but it gives up genuine multi-currency pricing that is already configured and paid for on the Stripe side. This spec restores it properly: the page displays the currency the customer will actually be charged. Live Prices today (both `livemode: true`, base currency GBP): | Price | Interval | GBP | EUR | USD | |---|---|---|---|---| | `price_1TbNshDLpLwvRJpKnuWjdU1x` | month | 700 | 700 | 999 | | `price_1TbNtWDLpLwvRJpKze87qOJ4` | year | 7000 | 7000 | 9499 | The EUR annual was corrected from 8000 to 7000 on 2026-07-29, so the "two months free" claim now holds in GBP and EUR (16.7%) and understates USD (20.8%). ## Goals - A visitor sees prices in the currency they will be charged, in the page copy, the buttons, and the saving claim. - The visitor can override the detected currency, and the choice sticks. - The displayed amounts are structurally incapable of disagreeing with what Stripe charges. - The monthly cooling-off waiver is effective for non-UK customers. - `/it/pricing` renders in Italian, matching how the landing page already works. ## Non-goals - Adding currencies beyond GBP/EUR/USD. Each would need `currency_options` on both live Prices first. - VAT calculation or Stripe Tax. `automatic_tax` is currently `false`; see Open Questions. - Changing the monthly/annual plan structure. Annual keeps its 14-day trial, monthly keeps immediate billing with a waiver. - Localising any public page other than `/pricing`. ## Design ### Two axes, both user-switchable | Axis | Values | Detection order | Cookie | |---|---|---|---| | Language | `en`, `it` | existing `detect_public_lang` | `rtm.lang` | | Currency | `gbp`, `eur`, `usd` | cookie → country → Accept-Language → `gbp` | `rtm.ccy` | An earlier draft added a third, non-switchable `jurisdiction` axis to select between UK Reg-36 and Italian art. 59 consent wording. It was dropped: see "Consent wording" below. Nothing legally operative is derived from IP geolocation. ### Data flow ``` GET /pricing (or /it/pricing) ├─ lang = detect_public_lang(cookie, accept-language, cf-country, user) ├─ currency = detect_currency(cookie, cf-country, accept-language) │ overridden by users.stripe_currency when set └─ amounts = pricing_catalog.get(currency) ↓ render symbol + amounts + computed saving %, in `lang` ↓ POST /api/stripe/checkout {cadence, currency} currency honoured only when the user has no stripe_customer_id ``` ### Components **`app/services/pricing_catalog.py`** (new) Reads both Prices with `expand[]=currency_options`, caches the result in memory for 1 hour, and exposes: ```python get(currency: str) -> PriceSet # monthly, annual, symbol, saving_pct available() -> list[str] # currencies present on BOTH prices ``` `saving_pct` is computed as `1 - annual / (12 * monthly)` and rounded down to a whole percent. It is never written by hand — this is what structurally prevents a repeat of the €80-vs-€84 drift. Knows nothing about HTTP, requests, or templates. Takes a Stripe client as a constructor argument so tests inject a fake. **Currency detection** — added to `app/services/locales.py` next to `detect_public_lang`, reusing its country tables rather than starting a parallel module. Pure function, no I/O: ```python detect_currency(cookie_ccy, cf_country, accept_language, allowed) -> str ``` Priority: an explicit cookie beats everything; then `CF-IPCountry`; then the first `Accept-Language` tag; then `gbp`. `allowed` is passed in by the caller from `pricing_catalog.available()` — the function stays pure and does no I/O of its own; anything not in `allowed` falls through to the next rule. The country table is carried over unchanged from the removed version, including `CA -> usd`. No CAD price exists, so every choice for Canada is a proxy; USD is the closest familiar one. Adding a real CAD `currency_options` entry would be the actual fix, and is out of scope. **`/pricing` route** (`app/routers/public.py`) gains the currency in its context and a sibling `/it/pricing` route. Copy moves into the existing `app/locales/{en,it}.yaml` under a `pricing.` key, matching the landing page. A `?ccy=` query parameter sets the cookie and redirects, so the switcher works without JavaScript. **`/api/stripe/checkout`** restores the `currency` field on `CheckoutRequest`, validated against `pricing_catalog.available()`, and passes it only when `user.stripe_customer_id` is unset. This reverts the mechanical part of `4169a67` while keeping its guarantee: the page and the charge always agree, because both now read the same catalog. ### Consent wording The monthly waiver currently cites *Regulation 36 of the Consumer Contracts Regulations 2013*. That is UK law; for a customer resident elsewhere the citation does not apply, and an ineffective waiver means a monthly subscriber retains the 14-day refund right the checkbox was meant to remove. UK Reg 36 and Italian `Codice del Consumo` art. 59 both implement Directive 2011/83/EU art. 16(m). The waiver takes effect from its substance — an express request for immediate performance plus an acknowledgement that the cancellation right is lost — not from the citation. Wording that states the substance and cites no statute is therefore effective under both regimes, whereas citing the wrong one is worse than citing none. New wording, in place of the current sentence: > I request that the service starts immediately, and I understand that > once it has started I lose my right to cancel and get a refund. The Terms-of-Service agreement in the same checkbox is unchanged. The Italian rendering of this sentence is a translation of substance, not of a statutory reference, so it carries the same weight as the existing `auth.ack` translations. This wording is subject to the legal sign-off already tracked on the launch blocker list. It is not a lawyer-authored sentence. ### Locked currency Stripe locks currency to the Customer at creation. A returning customer whose subscription lapsed could otherwise be shown €7 and billed £7. Add `users.stripe_currency` (`String(3)`, nullable), populated in `_grant_paid` from the subscription object. When set, `/pricing` renders that currency and disables the switcher with a one-line explanation. Requires a small Alembic migration. The simpler alternative — disable the switcher for anyone with a `stripe_customer_id`, without storing the currency — is rejected because it still shows a possibly-wrong currency; it only stops the user changing it. ### Failure modes | Condition | Behaviour | |---|---| | Stripe unreachable, warm cache | Serve stale cache indefinitely; log a warning | | Stripe unreachable, cold cache | Static GBP amounts, switcher hidden — i.e. exactly today's page | | Requested currency absent from a Price | Excluded from `available()`, so unreachable | | `?ccy=` with an unknown value | Ignored, cookie untouched | The page never returns an error because of a pricing lookup. ## Testing - `detect_currency` — table-driven unit tests over the priority chain, including values outside `allowed` falling through to the next rule. - `pricing_catalog` — fake Stripe client: happy path, `saving_pct` arithmetic, currency missing from one Price but not the other, cold-cache failure, stale-cache-on-failure. - Route tests — `/pricing` and `/it/pricing` render expected symbols and amounts per cookie/header combination; `?ccy=` sets the cookie. - **Cross-check test:** for each currency, assert the amount rendered in the page equals the amount Stripe would charge for the currency checkout sends. This is the regression guard for the original bug and is the most important test in the set. - Locked-currency test — a user with `stripe_currency` set sees that currency regardless of headers or cookie. ## Open questions 1. **EU VAT.** `automatic_tax` is `false`, so no VAT is charged. B2C digital services sold into the EU have no VAT threshold — VAT is due in the customer's member state from the first sale, normally via a non-Union OSS registration. Displaying EUR does not create this obligation, but selling to EU consumers does. Resolve before taking EUR money. Registration decision, not a code change. 2. **`billing_address_collection`.** Currently unset, so Stripe defaults to `auto` and may capture only a postal code. Setting it to `required` puts a country on every Customer record — useful for the VAT question above and for knowing where customers are. Recommended, independent of this feature. ## Out of scope / follow-ups - `customer.subscription.paused` and `.resumed` are subscribed at Stripe but absent from `_HANDLERS`. Harmless while pause is disabled in the portal configuration, but a live trap if it is ever enabled. - Localising `/terms` and `/privacy`, which the Italian pricing page will link to in English.