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>
This commit is contained in:
parent
4169a6767b
commit
1305aa77ff
1 changed files with 222 additions and 0 deletions
|
|
@ -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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue