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

9.3 KiB

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:

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:

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.

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.