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>
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/pricingrenders in Italian, matching how the landing page already works.
Non-goals
- Adding currencies beyond GBP/EUR/USD. Each would need
currency_optionson both live Prices first. - VAT calculation or Stripe Tax.
automatic_taxis currentlyfalse; 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.
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 outsideallowedfalling through to the next rule.pricing_catalog— fake Stripe client: happy path,saving_pctarithmetic, currency missing from one Price but not the other, cold-cache failure, stale-cache-on-failure.- Route tests —
/pricingand/it/pricingrender 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_currencyset sees that currency regardless of headers or cookie.
Open questions
- EU VAT.
automatic_taxisfalse, 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. billing_address_collection. Currently unset, so Stripe defaults toautoand may capture only a postal code. Setting it torequiredputs 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.pausedand.resumedare 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
/termsand/privacy, which the Italian pricing page will link to in English.