diff --git a/docs/superpowers/specs/2026-07-29-currency-localised-pricing-design.md b/docs/superpowers/specs/2026-07-29-currency-localised-pricing-design.md new file mode 100644 index 0000000..639a4c5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-currency-localised-pricing-design.md @@ -0,0 +1,222 @@ +# 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.