carlos / platform Public

Proposal: a versioned price book for fixed prices in USD, EUR and GBP, with the Go API, the console data contract and the public export

The amounts are a draft and not approved. Nothing is implemented by this
commit.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev b0bff421a10bd0ae41229e31f9f3c643ce4de962 parent 12e70d9
1 file changed, +206 −0
  • docs/superpowers/specs/2026-10-01-multi-currency-price-book-design.md +206 −0

CI passed — run details

diff --git a/docs/superpowers/specs/2026-10-01-multi-currency-price-book-design.md b/docs/superpowers/specs/2026-10-01-multi-currency-price-book-design.md
new file mode 100644
index 000000000..68fdc22da
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-01-multi-currency-price-book-design.md
@@ -0,0 +1,206 @@
+# Fixed prices in three currencies: price book, API and data contract
+
+Status: proposal, written 2026-10-01 on `feat/carloku-multi-currency-20261001` (off the paid-launch branch at `12e70d95`). Billing is OFF. Nothing here writes to Stripe or to production. The amounts are a draft that Paul has not approved; the book that carries them is marked `draft` and live mode refuses a draft book.
+
+It amends the approved pricing spec (`2026-09-07-carloku-pricing-plans-design.md`) in one place, on Paul's instruction of 2026-10-01: prices are fixed in USD, EUR and GBP, and Stripe's Adaptive Pricing is switched off on every Checkout Session. The spec's "no `currency_options`" rule stays.
+
+## Rules
+
+1. Three currencies, lower case as Stripe writes them: `usd`, `eur`, `gbp`.
+2. The default for a buyer is decided by billing country: an EU member state gets `eur`, `GB` gets `gbp`, everything else and an unknown country get `usd`. A browser locale only suggests; it is never taken as a country.
+3. A buyer may override the default. An explicit currency that is not one of the three is refused. It is never replaced by a default.
+4. The affordability band comes from the billing country and from nothing else. Choosing a currency changes no allowance and no band.
+5. The currency is chosen once, at checkout, and stored. The subscription and every metered overage invoice for it are in that currency. Nothing is ever converted.
+6. An account's currency cannot change while it has a subscription that has not ended. A calendar month whose paid spans are in two currencies is held for review, never priced.
+7. A row written before currencies existed has no currency. That means EUR under the legacy arithmetic, and only for such rows: a new row with no currency is refused.
+
+## The price book
+
+A price book is embedded, versioned, immutable once published, and separate from the terms version. Terms (`plans.json`) keep the plans' resources, quantities and band factors. A book adds money: for each plan, band and currency, the price of one member for one year in minor units; for each currency, the four overage rates and the floor under which an overage invoice is waived.
+
+`internal/plans/pricebook.json`, one object per book:
+
+```json
+{
+ "version": "2026-10-01-draft1",
+ "terms_version": "2026-09-07-v1",
+ "status": "draft",
+ "default_currency": "usd",
+ "currencies": ["usd", "eur", "gbp"],
+ "country_currency": {"eur": ["AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE"], "gbp": ["GB"]},
+ "annual_base_minor": {
+ "personal": {"usd": 2800, "eur": 2400, "gbp": 2200},
+ "community": {"usd": 600, "eur": 500, "gbp": 450},
+ "business": {"usd": 6000, "eur": 5000, "gbp": 4500}
+ },
+ "annual_unit_minor": {
+ "personal": {"usd": {"A": 2800, "B": 2100, "C": 1400, "D": 980}, "eur": {"A": 2400, "B": 1800, "C": 1200, "D": 840}, "gbp": {"A": 2200, "B": 1650, "C": 1100, "D": 770}},
+ "community": {"usd": {"A": 600, "B": 450, "C": 300, "D": 210}, "eur": {"A": 500, "B": 375, "C": 250, "D": 175}, "gbp": {"A": 450, "B": 338, "C": 225, "D": 158}},
+ "business": {"usd": {"A": 6000, "B": 4500, "C": 3000, "D": 2100}, "eur": {"A": 5000, "B": 3750, "C": 2500, "D": 1750}, "gbp": {"A": 4500, "B": 3375, "C": 2250, "D": 1575}}
+ },
+ "overage_rates_micro": {
+ "usd": {"awake_hour": 12000, "storage_gb_month": 127200, "transfer_gb": 496800, "writes_1000": 27600},
+ "eur": {"awake_hour": 10000, "storage_gb_month": 106000, "transfer_gb": 414000, "writes_1000": 23000},
+ "gbp": {"awake_hour": 9000, "storage_gb_month": 95400, "transfer_gb": 372600, "writes_1000": 20700}
+ },
+ "overage_floor_micro": {"usd": 1200000, "eur": 1000000, "gbp": 900000}
+}
+```
+
+The 36 unit prices are written out, and validated on load against the rule that produced them: `annual_unit_minor[plan][currency][band]` must equal `annual_base_minor[plan][currency] * band_factor_bps / 10000`, rounded half up to a whole minor unit, per member, before any quantity. An annual price is the unit price times the quantity, exactly. So what Stripe holds as `unit_amount` is a number in the file, and a hand edit that breaks the rule fails the build.
+
+The EUR column equals what the legacy arithmetic gives for every plan and band (all twelve are whole cents), so an account priced under the legacy terms and one priced in EUR under this book pay the same.
+
+Draft amounts, not approved: the base prices above, the overage rates (EUR times 1.2 for USD and 0.9 for GBP, all exact) and the floors.
+
+`status` is `draft` or `approved`. A draft book can be quoted, exported and used in test mode. Console configuration in live mode with a draft book is refused.
+
+## Go API (`internal/plans`)
+
+```go
+type Currency string
+
+const (
+ USD Currency = "usd"
+ EUR Currency = "eur"
+ GBP Currency = "gbp"
+)
+
+var (
+ ErrUnknownCurrency = errors.New("plans: unknown_currency")
+ ErrUnknownPriceBook = errors.New("plans: unknown_price_book")
+ ErrCurrencyMismatch = errors.New("plans: currency_mismatch")
+)
+
+// ParseCurrency accepts exactly "usd", "eur" or "gbp". Anything else,
+// the empty string included, is ErrUnknownCurrency.
+func ParseCurrency(s string) (Currency, error)
+
+// CurrencyForCountry is the default for a billing country under a book:
+// never an error for an unknown or empty country, which gets the book's
+// default.
+func CurrencyForCountry(book, country string) (Currency, error)
+
+// ChooseCurrency is the one rule for a purchase: explicit when given and
+// valid, refused when given and not valid, the country's default when
+// not given.
+func ChooseCurrency(book, explicit, country string) (Currency, error)
+
+// LegacyPriceBook is the book of a row written before books existed:
+// EUR, priced from the terms' own figures.
+const LegacyPriceBook = ""
+const CurrentPriceBook = "2026-10-01-draft1"
+
+// PriceTerms is the commercial identity of a purchase. It is stored with
+// the purchase and never changes for it.
+type PriceTerms struct {
+ TermsVersion string `json:"terms_version"`
+ PriceBook string `json:"price_book,omitempty"`
+ Currency Currency `json:"currency,omitempty"`
+}
+
+func (t PriceTerms) Validate() error // legacy: book "" and currency "" or "eur"; otherwise a known book and one of its currencies
+func (t PriceTerms) Legacy() bool
+func (t PriceTerms) Money() Currency // "eur" for legacy
+
+type Price struct {
+ Terms PriceTerms
+ Plan, Band string
+ Quantity int64
+ UnitMinor int64 // one member, one year
+ AnnualMinor int64 // UnitMinor * Quantity
+ Allowances Resources // identical for every currency
+}
+
+func PriceFor(t PriceTerms, plan, band string, qty int64) (*Price, error)
+
+type Charges struct {
+ Currency Currency
+ AwakeMicro, StorageMicro, TransferMicro, WritesMicro int64
+ TotalMicro int64
+}
+
+func OverageFor(t PriceTerms, plan string, usage, allowance Resources) (*Charges, error)
+
+type Invoice struct {
+ Currency Currency
+ TotalMicro, TotalMinor int64
+ Waived bool
+}
+
+// OverageInvoiceFor applies the house rule and the book's floor for the
+// currency, rounds once, and splits the total into lines that sum to it.
+func OverageInvoiceFor(t PriceTerms, c Charges, house bool) (*Invoice, []OverageLine, error)
+
+func BookApproved(book string) (bool, error)
+func PublicPriceBook(book string) ([]byte, error) // the export below, byte-stable
+```
+
+`Quote`, `CalculateOverage`, `InvoiceOverageLines` and the `MicroEUR` types stay as they are, for legacy rows and for snapshots already written. The new functions give the same numbers for legacy terms.
+
+`Assignment` gains `Currency` and `PriceBook` (both `omitempty`, so an existing snapshot marshals byte for byte as before). `AllowanceForMonth` copies them to `MonthAllowance` and refuses, with `ErrAssignmentHistory`, a month whose paid spans disagree on either.
+
+## Console data contract
+
+New columns, all `TEXT NOT NULL DEFAULT ''`, on `billing_checkout_intents`, `billing_accounts` and `billing_assignments`: `currency`, `price_book`. Empty in both means a legacy row.
+
+- **Checkout** (`POST /a/{sqid}/billing/checkout`): a new optional form field `currency`. Absent or empty: the country's default. Present and not `usd`, `eur` or `gbp`: 400, nothing is created. The intent stores the chosen currency and book. A retry with a different currency while an intent is open is a different intent, exactly as a different plan is today.
+- **A currency already held**: checkout in another currency is refused while the account's subscription has not ended (`canceled` or `incomplete_expired`).
+- **Stripe price identity**: the configured price for (book, plan, band, currency) must have that `currency`, `unit_amount` equal to the book's unit price, no `currency_options`, and metadata `price_book`, `currency`, `terms_version`, `plan`, `band` that all match. The checks that exist today (per-unit, yearly, licensed, exclusive tax, active, mode) stay.
+- **Checkout Session**: created with `adaptive_pricing[enabled]=false`, and with `currency`, `price_book` in the session and subscription metadata. The session that comes back, and the subscription reconciled later, must be in the intent's currency; a subscription whose price is not the configured one for its stored (book, plan, band, currency) is an identity mismatch, as a wrong price is today.
+- **Configuration**: `CONSOLE_STRIPE_PRICES` holds 36 entries keyed `"<price_book>/<plan>/<band>/<currency>"`. The twelve legacy keys `"<terms>/<plan>/<band>"` may be given as well and are used only to reconcile legacy subscriptions.
+- **Monthly calculation**: a month priced from a book is a version 2 snapshot: `currency`, `price_book`, and amounts under currency-neutral names. A version 1 snapshot is EUR, is read as it was written, and is never priced again. A stored calculation whose currency is not the month's assignment currency is refused.
+- **Overage invoice**: the invoice and every invoice item are created in the calculation's currency and verified against it on every read. The currency is part of each request's parameters and so of its request hash; legacy EUR requests hash as they do today.
+- **Quantity worker**: reads the price for the account's stored (book, plan, band, currency).
+
+## Carrying a choice through sign-in
+
+The pricing page links to the console with `?currency=<code>`. The console's sign-in entry points accept it, validate it, and keep it in a short-lived cookie (`carloku_currency`, host-only, `SameSite=Lax`, one hour). An invalid value is dropped and no cookie is set. The billing page preselects from the cookie, else from nothing: the country the owner then picks decides the default. The cookie is a suggestion; only the `currency` field of the checkout form is a choice. No geo lookup, no external service.
+
+Interface for the billing page (the layout branch owns the template): the page view gains `Currencies []string`, `Currency string` (the preselected one, may be empty) and `Prices` as in the export below, and the checkout form posts `currency`.
+
+## Public export for the site
+
+`PublicPriceBook` renders this from the embedded terms, book and country bands. `go run ./cmd/pricebook-export` writes it; a test fails if the committed copy differs. The site formats from it and holds no price of its own.
+
+```json
+{
+ "schema": 1,
+ "price_book": "2026-10-01-draft1",
+ "terms_version": "2026-09-07-v1",
+ "status": "draft",
+ "default_currency": "usd",
+ "currencies": [
+ {"code": "usd", "symbol": "$", "minor_per_major": 100},
+ {"code": "eur", "symbol": "€", "minor_per_major": 100},
+ {"code": "gbp", "symbol": "£", "minor_per_major": 100}
+ ],
+ "country_currency": {"AT": "eur", "GB": "gbp"},
+ "country_band": {"IE": "A"},
+ "band_factors_bps": {"A": 10000, "B": 7500, "C": 5000, "D": 3500},
+ "plans": [
+ {
+ "key": "personal",
+ "min_quantity": 1,
+ "max_quantity": 1,
+ "band_scaled": true,
+ "annual_unit_minor": {"usd": {"A": 2800, "B": 2100, "C": 1400, "D": 980}, "eur": {}, "gbp": {}},
+ "resources_per_member": {"awake_seconds": 306000, "storage_byte_months": 2000000000, "transfer_bytes": 2000000000, "writes": 0}
+ }
+ ],
+ "overage": {
+ "usd": {"awake_hour_micro": 12000, "storage_gb_month_micro": 127200, "transfer_gb_micro": 496800, "writes_1000_micro": 27600, "invoice_floor_micro": 1200000}
+ }
+}
+```
+
+`country_currency` lists only countries whose default is not `default_currency`. `country_band` is every country the book sells to. `plans` is in display order: free, personal, community, business; free has zero prices. `resources_per_member` is band A; another band's allowance is that figure times the band factor, divided by 10000, rounded down, after multiplying by the quantity. A micro is one millionth of the currency's major unit. Keys are sorted and the output is byte-stable.
+
+## What is not decided or not provable here
+
+1. The amounts. The book is `draft` until Paul approves them; approving is a new book version with `status: approved`, not an edit.
+2. Stripe keeps a customer in one currency while it has a subscription or open invoice items. Whether a customer whose subscription has ended can start one in another currency has not been tried: test mode is not authorized. The console refuses a change earlier than Stripe would, and treats Stripe's refusal as an ordinary failed checkout.
+3. That `adaptive_pricing[enabled]=false` is honoured and that a session comes back in the price's currency is read from Stripe's documentation and checked on every session the console creates. It has not been run against Stripe.
+4. Tax: checkout still requires the Irish standard and OSS registrations and nothing else. Selling in GBP or USD does not change which registrations exist. Whether UK VAT registration is wanted before GBP is offered is Paul's call.
+5. `GB` alone maps to `gbp`. Jersey, Guernsey and the Isle of Man get `usd` unless listed.
+6. The 50% code and the house coupon are percentages and need no currency.