read.markets/docs/superpowers/specs/2026-07-29-currency-localised-pricing-design.md
Giorgio Gilestro 1305aa77ff spec: currency-localised pricing design
Restores multi-currency pricing that 4169a67 disabled, with the page
and the charge reading the same source so they cannot drift apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 11:05:25 +02:00

222 lines
9.3 KiB
Markdown

# 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.