carlos / platform Public

Multi-currency: tests for the console's checkout, reconcile, calculation and invoice paths; the annual prices recorded as confirmed for implementation

Tests drive checkout, reconcile, the monthly calculation and the invoice
engine in each currency through the same fakes as the legacy tests, and
show the refusals: an unknown currency, a provider price that is not the
book's, a session or subscription in another currency, a subscription
that changes currency, a month paid for in two, a stored calculation
that is not whole. A legacy month still marshals byte for byte and its
requests still hash as they did. Forty-three mutation checks are
recorded in DISPOSITIONS-multi-currency.md.

Paul confirmed the annual base prices for implementation, not for
production; overage rates and floors remain proposed. The book stays a
draft and live mode refuses it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 9d104f1d27b71c61948b91c722884d2fb3c72637 parent 6c1a9c2
6 files changed, +1060 −13
  • cmd/console/billing_test.go +11 −0
  • docs/superpowers/specs/2026-10-01-multi-currency-price-book-design.md +22 −5
  • docs/superpowers/specs/DISPOSITIONS-multi-currency.md +19 −0
  • internal/console/billingcurrency_test.go +977 −0
  • internal/console/billinginvoice_test.go +28 −8
  • internal/console/billingservice_test.go +3 −0

CI passed — run details

diff --git a/cmd/console/billing_test.go b/cmd/console/billing_test.go
index 768de5228..937b37495 100644
--- a/cmd/console/billing_test.go
+++ b/cmd/console/billing_test.go
@@ -136,6 +136,17 @@ func TestBillingConfigPriceBookAndCatalog(t *testing.T) {
}
}
},
+ "no book, and only the twelve legacy keys": func(env map[string]string, prices map[string]string) {
+ delete(env, "CONSOLE_BILLING_PRICE_BOOK")
+ for k := range prices {
+ delete(prices, k)
+ }
+ for k, v := range billingPricesFixture(true) {
+ if strings.Count(k, "/") == 2 {
+ prices[k] = v
+ }
+ }
+ },
"some of the legacy keys": func(_ map[string]string, prices map[string]string) {
prices[plans.CurrentTermsVersion+"/personal/A"] = "price_legacy_personalA"
},
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
index caebfd93d..d03ef7aa8 100644
--- 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
@@ -1,6 +1,8 @@
# 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.
+Status: built on `feat/carloku-multi-currency-20261001` (off the paid-launch branch at `12e70d95`), written 2026-10-01. Billing is OFF. Nothing here writes to Stripe or to production.
+
+The amounts: Paul confirmed the annual base prices on 2026-10-01 for implementation, not for production (Personal 28 USD / 24 EUR / 22 GBP; Community 6 / 5 / 4.50 per member; Business 60 / 50 / 45 per member; team minimum ten). The overage rates and the invoice floors are proposed and not approved. So the book that carries them is still marked `draft`, and live mode refuses a draft book: approving it for production is a new book version with `status: approved`.
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.
@@ -51,7 +53,7 @@ The 36 unit prices are written out, and validated on load against the rule that
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.
+The base prices above are the ones Paul confirmed for implementation. The overage rates (EUR times 1.2 for USD and 0.9 for GBP, all exact) and the floors are proposed.
`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.
@@ -148,7 +150,7 @@ New columns, all `TEXT NOT NULL DEFAULT ''`, on `billing_checkout_intents`, `bil
- **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.
+- **Configuration**: `CONSOLE_BILLING_PRICE_BOOK` names the book and is required. `CONSOLE_STRIPE_PRICES` holds 36 entries keyed `"<price_book>/<plan>/<band>/<currency>"`. The twelve legacy keys `"<terms>/<plan>/<band>"` may be given as well, all or none, and are used only to reconcile legacy subscriptions. Live mode with a draft book is refused.
- **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).
@@ -157,7 +159,7 @@ New columns, all `TEXT NOT NULL DEFAULT ''`, on `billing_checkout_intents`, `bil
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`.
+Interface for the billing page (the layout branch owns the template and the view): `s.billingCurrencies()` gives the currencies to offer, `s.suggestedCurrency(r)` the one to preselect (may be empty), the prices come from `plans.PriceFor` or the export below, and the checkout form posts `currency`.
## Public export for the site
@@ -196,9 +198,24 @@ Interface for the billing page (the layout branch owns the template): the page v
`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 was built, and where
+
+- `internal/plans/pricebook.go`, `pricebook.json`, `public-pricebook.json`, `cmd/pricebook-export`: the book, its validation, `ChooseCurrency`, `PriceFor`, `OverageFor`, `OverageInvoiceFor`, the export.
+- `internal/plans/month.go`: assignments and month allowances carry the book and currency; a month in two currencies is refused.
+- `internal/billingstripe`: a currency on every checkout session, draft invoice and invoice item; Adaptive Pricing off; a session that comes back in another currency, converted, or with Adaptive Pricing on is refused; sessions, subscriptions and items report their currency.
+- `internal/console/billingstore.go`: the six columns; an intent and a recorded subscription need whole price terms; a subscription never changes currency or book.
+- `internal/console/billingservice.go`: checkout chooses and stores the currency, checks the provider's price against the book, and reconcile requires the configured price for exactly the stored plan, band, book and currency and a subscription in that currency.
+- `internal/console/billingcalculation.go`, `billinginvoice.go`: the version 2 calculation; the engine sends and verifies the calculation's own currency; legacy request hashes unchanged.
+- `internal/console/billingcurrency.go`: the cookie and the list of currencies offered.
+- `cmd/console/billing.go`: `CONSOLE_BILLING_PRICE_BOOK` is required; 36 prices, or 48 with all twelve legacy keys; live mode refuses a draft book.
+
+With no book configured (`BillingConfig.PriceBook` empty, which only tests and the demo do) the console behaves as it did: EUR, the terms' own figures, rows with no currency. Every billing test from before this change passes in that mode, unedited except for call signatures and fakes that now report a currency.
+
+Not touched: the billing page (`billingpage.go` and its template), which still shows EUR from `plans.Quote`. The layout branch owns it. What it can call: `s.billingCurrencies()` (the currencies to offer, in order), `s.suggestedCurrency(r)` (the preselection, may be empty), `plans.PriceFor(plans.PriceTerms{...}, plan, band, qty)` for a price in minor units, and the checkout form field `currency`. `startBillingCheckout` gained a `currency` argument before `now`.
+
## 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.
+1. Production. The annual prices are confirmed for implementation only, and the overage rates and floors are proposed. The book stays `draft`; approving it 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.
diff --git a/docs/superpowers/specs/DISPOSITIONS-multi-currency.md b/docs/superpowers/specs/DISPOSITIONS-multi-currency.md
new file mode 100644
index 000000000..96985342f
--- /dev/null
+++ b/docs/superpowers/specs/DISPOSITIONS-multi-currency.md
@@ -0,0 +1,19 @@
+# Multi-currency price book: what was checked before review
+
+Branch `feat/carloku-multi-currency-20261001`. Design and contract: `2026-10-01-multi-currency-price-book-design.md`. Billing is off; nothing here writes to Stripe. Not yet read by a reviewer.
+
+## Mutation checks
+
+Run in a scratch copy of the tree, each mutation alone, against the tests named for it. Forty-three in all.
+
+Forty failed their tests: checkout ignoring the buyer's choice; an unknown currency falling back to the default; the provider price's currency, its amount, and its own metadata unchecked; Adaptive Pricing not switched off; the session that comes back unchecked; reconcile not comparing the subscription's currency; a subscription changing currency in the store; a book month priced at the legacy rates; the invoice's, an item's and a line's currency unverified; the invoice preflight ignoring the subscription's currency; a legacy request hashed with its currency; the frozen month's currency not compared with the recorded assignment; the cookie keeping any value; a forged cookie believed; a month paid for in two currencies; the price key ignoring the currency; any stored calculation counted well formed; charges invoiced in another currency; the quantity worker ignoring the stored currency; the book's rounding rule unenforced; a book's EUR differing from the terms'; live mode accepting a draft book; the EUR floor used for every currency; a session bound to its intent with its currency unchecked; an intent with a book and no currency opened; the session metadata omitting the currency; reconcile not checking the completed session's currency; reconcile not comparing the intent's price terms; binding not comparing the session's price terms with the intent's; a recorded state needing no valid price terms.
+
+Three survived, each because another check refuses the same thing, so the mutated line is redundant today and stays as a second fence:
+
+- the comparison of an open intent's currency with the new request's, and the same comparison when an intent is begun: a different currency is always a different provider price, which both already compare;
+- the configuration's explicit refusal of an empty book name: with no book the price map cannot have the keys it then requires.
+
+## What the tests cannot show
+
+- Stripe itself. Every provider answer here is a fake's. That a session created with `adaptive_pricing[enabled]=false` and `currency` comes back in that currency, that Stripe refuses a price in another currency, and what Stripe does when a customer whose subscription ended starts one in another currency, are read from documentation and need test mode, which is not authorized.
+- The legacy request hashes are shown equal to the hash of the parameter shapes as they were, reconstructed in the test. No operation journaled by an earlier build exists to replay.
diff --git a/internal/console/billingcurrency_test.go b/internal/console/billingcurrency_test.go
new file mode 100644
index 000000000..23db4538d
--- /dev/null
+++ b/internal/console/billingcurrency_test.go
@@ -0,0 +1,977 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/billingstripe"
+ "github.com/carlosframework/platform/internal/plans"
+ "github.com/carlosframework/platform/internal/usage"
+)
+
+func bookTerms(c plans.Currency) plans.PriceTerms {
+ return plans.PriceTerms{TermsVersion: plans.CurrentTermsVersion, PriceBook: plans.CurrentPriceBook, Currency: c}
+}
+
+func currencyPriceID(plan, band string, c plans.Currency) string {
+ return "price_" + plan + "_" + band + "_" + string(c)
+}
+
+// newCurrencyFixture is newBillingFixture with the console selling from the
+// current price book: every country's real band, and a provider price for
+// every plan, band and currency, each exactly what the book says and
+// saying so in its own metadata. The legacy price of the base fixture stays
+// configured, as a deployment that sold before books would have it.
+func newCurrencyFixture(t *testing.T) (*consoleFixture, *fakeBillingStripe, *Account) {
+ t.Helper()
+ fx, f, acct := newBillingFixture(t)
+ cfg := &fx.srv.billing.cfg
+ cfg.PriceBook, cfg.CountryBands = plans.CurrentPriceBook, plans.CountryBands()
+ f.prices = map[string]map[string]any{}
+ for _, plan := range []string{plans.PlanPersonal, plans.PlanCommunity, plans.PlanBusiness} {
+ qty := int64(1)
+ if plan != plans.PlanPersonal {
+ qty = 10
+ }
+ for _, band := range []string{plans.BandA, plans.BandB, plans.BandC, plans.BandD} {
+ for _, c := range []plans.Currency{plans.USD, plans.EUR, plans.GBP} {
+ terms := bookTerms(c)
+ price, err := plans.PriceFor(terms, plan, band, qty)
+ if err != nil {
+ t.Fatal(err)
+ }
+ id := currencyPriceID(plan, band, c)
+ cfg.Prices[billingPriceKey(terms, plan, band)] = id
+ f.prices[id] = map[string]any{"id": id, "product": "prod_personal", "active": true, "livemode": false, "currency": string(c), "unit_amount": price.UnitMinor, "tax_behavior": "exclusive", "billing_scheme": "per_unit",
+ "metadata": map[string]string{"terms_version": plans.CurrentTermsVersion, "plan": plan, "band": band, "price_book": plans.CurrentPriceBook, "currency": string(c)},
+ "recurring": map[string]any{"interval": "year", "interval_count": 1, "usage_type": "licensed"}}
+ }
+ }
+ }
+ return fx, f, acct
+}
+
+// The buyer's currency: the billing country's default, or the one they
+// chose. Either way the band, and so every allowance, is the country's.
+// What reaches the provider is the price for exactly that plan, band and
+// currency, a session asked for in that currency with Adaptive Pricing
+// off, and the book and currency in the metadata of the session and of
+// the subscription it will create.
+func TestCheckoutCurrencyIsTheCountrysDefaultOrTheBuyersChoice(t *testing.T) {
+ for name, tc := range map[string]struct {
+ country, postal, explicit string
+ want plans.Currency
+ }{
+ "Ireland": {"IE", "", "", plans.EUR},
+ "the United Kingdom": {"GB", "", "", plans.GBP},
+ "the United States": {"US", "94107", "", plans.USD},
+ "Poland": {"PL", "", "", plans.EUR},
+ "Switzerland": {"CH", "", "", plans.USD},
+ "Ireland, choosing dollars": {"IE", "", "usd", plans.USD},
+ "Poland, choosing pounds": {"PL", "", "gbp", plans.GBP},
+ "the United States, in euros": {"US", "94107", "eur", plans.EUR},
+ "the United Kingdom, in euro": {"GB", "", "eur", plans.EUR},
+ } {
+ t.Run(name, func(t *testing.T) {
+ fx, f, acct := newCurrencyFixture(t)
+ f.customerCountry = tc.country
+ band, err := plans.CountryBand(tc.country)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, tc.country, tc.postal, tc.explicit, f.now); err != nil {
+ t.Fatal(err)
+ }
+ intent, err := fx.st.billingCheckoutIntent(acct.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if intent.Currency != string(tc.want) || intent.PriceBook != plans.CurrentPriceBook || intent.Band != band || intent.Price != currencyPriceID(plans.PlanPersonal, band, tc.want) {
+ t.Fatalf("intent %+v; want %s at the country's band %s", intent, tc.want, band)
+ }
+ form := f.forms[len(f.forms)-1]
+ for key, want := range map[string]string{
+ "currency": string(tc.want), "adaptive_pricing[enabled]": "false", "line_items[0][price]": intent.Price,
+ "metadata[currency]": string(tc.want), "metadata[price_book]": plans.CurrentPriceBook, "metadata[band]": band,
+ "subscription_data[metadata][currency]": string(tc.want), "subscription_data[metadata][price_book]": plans.CurrentPriceBook,
+ } {
+ if got := form.Get(key); got != want {
+ t.Errorf("checkout form %s = %q, want %q", key, got, want)
+ }
+ }
+ // The allowance the purchase carries is the band's, whatever
+ // the currency.
+ inCurrency, err := plans.PriceFor(intent.priceTerms(), intent.Plan, intent.Band, intent.Quantity)
+ if err != nil {
+ t.Fatal(err)
+ }
+ legacy, err := plans.Quote(plans.CurrentTermsVersion, intent.Plan, band, intent.Quantity)
+ if err != nil || inCurrency.Allowances != legacy.Allowances {
+ t.Fatalf("allowances in %s differ from the band's: %+v, %+v (%v)", tc.want, inCurrency.Allowances, legacy.Allowances, err)
+ }
+ })
+ }
+}
+
+// A currency somebody asked for that is not one sold is refused. Nothing
+// is read from or written to the provider, no intent is opened, and the
+// country's default does not stand in for it.
+func TestCheckoutRefusesACurrencyThatIsNotSold(t *testing.T) {
+ for _, bad := range []string{"jpy", "EUR", "Gbp", " ", "eur ", "euro", "chf"} {
+ fx, f, acct := newCurrencyFixture(t)
+ _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "IE", "", bad, f.now)
+ if !errors.Is(err, errBillingCurrency) {
+ t.Errorf("%q: %v, want errBillingCurrency", bad, err)
+ }
+ if f.requests != 0 {
+ t.Errorf("%q: %d requests reached the provider", bad, f.requests)
+ }
+ if _, err := fx.st.billingCheckoutIntent(acct.ID); err == nil {
+ t.Errorf("%q: an intent was opened", bad)
+ }
+ }
+ // A console still selling the way it did before books sells in EUR
+ // and nothing else.
+ fx, f, acct := newBillingFixture(t)
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "IE", "", "usd", f.now); !errors.Is(err, errBillingCurrency) || f.requests != 0 {
+ t.Fatalf("usd from a console with no book: %v, %d requests", err, f.requests)
+ }
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "IE", "", "eur", f.now); err != nil {
+ t.Fatalf("eur from a console with no book: %v", err)
+ }
+ if intent, err := fx.st.billingCheckoutIntent(acct.ID); err != nil || intent.Currency != "" || intent.PriceBook != "" {
+ t.Fatalf("a purchase with no book is stored with no currency: %+v %v", intent, err)
+ }
+}
+
+// Through the door: the form's currency field, posted by an owner who has
+// just confirmed who they are. An unknown currency is a 400 and nothing
+// else happens.
+func TestCheckoutFormCurrencyField(t *testing.T) {
+ post := func(t *testing.T, currency string) (*httptest.ResponseRecorder, *consoleFixture, *fakeBillingStripe, *Account) {
+ fx, f, acct := newCurrencyFixture(t)
+ const owner = "paul@keymail.dev"
+ raw, err := fx.st.CreateSession(owner, "", time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.RecordStepUp(raw, owner, time.Now(), time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ form := url.Values{"csrf": {raw}, "plan": {"personal"}, "country": {"IE"}, "consent": {plans.CurrentTermsVersion}}
+ if currency != "-" {
+ form.Set("currency", currency)
+ }
+ r := httptest.NewRequest(http.MethodPost, fx.path("/billing/checkout"), strings.NewReader(form.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.AddCookie(&http.Cookie{Name: sessionCookie, Value: raw})
+ w := httptest.NewRecorder()
+ fx.srv.ServeHTTP(w, r)
+ return w, fx, f, acct
+ }
+ for currency, want := range map[string]string{"-": "eur", "": "eur", "gbp": "gbp", "usd": "usd"} {
+ w, fx, _, acct := post(t, currency)
+ if w.Code != http.StatusSeeOther || !strings.HasPrefix(w.Header().Get("Location"), "https://checkout.stripe.com/") {
+ t.Fatalf("currency %q: %d %s", currency, w.Code, w.Header().Get("Location"))
+ }
+ if intent, err := fx.st.billingCheckoutIntent(acct.ID); err != nil || intent.Currency != want {
+ t.Fatalf("currency %q: intent %+v %v, want %s", currency, intent, err, want)
+ }
+ }
+ w, fx, f, acct := post(t, "jpy")
+ if w.Code != http.StatusBadRequest || strings.TrimSpace(w.Body.String()) != "currency_invalid" {
+ t.Fatalf("an unknown currency: %d %q", w.Code, w.Body.String())
+ }
+ if _, err := fx.st.billingCheckoutIntent(acct.ID); err == nil || f.checkoutCalls != 0 {
+ t.Fatalf("an unknown currency opened an intent or a session (%d checkout calls)", f.checkoutCalls)
+ }
+}
+
+// The provider's price has to be this purchase's price. One that is in
+// another currency, for another amount, or that does not say which book
+// and currency it is for, is not sold, and no session is created.
+func TestCheckoutRefusesAProviderPriceThatIsNotTheBooks(t *testing.T) {
+ id := currencyPriceID(plans.PlanPersonal, plans.BandA, plans.GBP)
+ for name, spoil := range map[string]func(price map[string]any){
+ "in another currency": func(p map[string]any) { p["currency"] = "eur" },
+ "one penny more": func(p map[string]any) { p["unit_amount"] = int64(2201) },
+ "the euro amount under gbp": func(p map[string]any) { p["unit_amount"] = int64(2400) },
+ "metadata for another currency": func(p map[string]any) { p["metadata"].(map[string]string)["currency"] = "usd" },
+ "metadata with no book": func(p map[string]any) { delete(p["metadata"].(map[string]string), "price_book") },
+ "metadata with no currency": func(p map[string]any) { delete(p["metadata"].(map[string]string), "currency") },
+ "metadata for another book": func(p map[string]any) { p["metadata"].(map[string]string)["price_book"] = "2031-01-01-v9" },
+ "metadata for another band": func(p map[string]any) { p["metadata"].(map[string]string)["band"] = "B" },
+ "carrying currency options": func(p map[string]any) {
+ p["currency_options"] = map[string]any{"eur": map[string]any{"unit_amount": 2400}}
+ },
+ "another price's id at this path": func(p map[string]any) { p["id"] = currencyPriceID(plans.PlanPersonal, plans.BandA, plans.EUR) },
+ } {
+ t.Run(name, func(t *testing.T) {
+ fx, f, acct := newCurrencyFixture(t)
+ f.customerCountry = "GB"
+ spoil(f.prices[id])
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "GB", "", "", f.now); !errors.Is(err, errBillingUnavailable) {
+ t.Fatalf("%v, want errBillingUnavailable", err)
+ }
+ if f.checkoutCalls != 0 {
+ t.Fatal("a session was created for a price that is not the book's")
+ }
+ })
+ }
+ // A price configured under the wrong currency's key.
+ fx, f, acct := newCurrencyFixture(t)
+ f.customerCountry = "GB"
+ fx.srv.billing.cfg.Prices[billingPriceKey(bookTerms(plans.GBP), plans.PlanPersonal, plans.BandA)] = currencyPriceID(plans.PlanPersonal, plans.BandA, plans.EUR)
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "GB", "", "", f.now); !errors.Is(err, errBillingUnavailable) || f.checkoutCalls != 0 {
+ t.Fatalf("the euro price under the gbp key: %v, %d checkout calls", err, f.checkoutCalls)
+ }
+}
+
+// A session that comes back in another currency than it was asked for is
+// not kept: the intent has no session, and nothing is offered to the buyer.
+func TestCheckoutRefusesASessionInAnotherCurrency(t *testing.T) {
+ fx, f, acct := newCurrencyFixture(t)
+ f.customerCountry, f.sessionCurrency = "GB", "eur"
+ dest, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "GB", "", "", f.now)
+ var stripeErr *billingstripe.Error
+ if dest != "" || !errors.As(err, &stripeErr) || stripeErr.Code != billingstripe.CodeCurrencyMismatch {
+ t.Fatalf("dest %q err %v, want a currency mismatch and no destination", dest, err)
+ }
+ if intent, err := fx.st.billingCheckoutIntent(acct.ID); err != nil || intent.SessionID != "" || intent.URL != "" {
+ t.Fatalf("the session was kept: %+v %v", intent, err)
+ }
+}
+
+// Changing currency while a checkout is open is a different purchase: the
+// open session is expired first and a new intent opened in the new
+// currency, as for a changed plan or country.
+func TestCheckoutInAnotherCurrencyReplacesTheOpenSession(t *testing.T) {
+ fx, f, acct := newCurrencyFixture(t)
+ ctx := context.Background()
+ if _, err := fx.srv.startBillingCheckout(ctx, acct, plans.PlanPersonal, "IE", "", "", f.now); err != nil {
+ t.Fatal(err)
+ }
+ first, _ := fx.st.billingCheckoutIntent(acct.ID)
+ // The same request again resumes the same session.
+ if _, err := fx.srv.startBillingCheckout(ctx, acct, plans.PlanPersonal, "IE", "", "eur", f.now.Add(time.Second)); err != nil {
+ t.Fatal(err)
+ }
+ if again, _ := fx.st.billingCheckoutIntent(acct.ID); again.ID != first.ID || f.expireCalls != 0 || f.checkoutCalls != 1 {
+ t.Fatalf("the same currency again: intent %s then %s, %d expiries, %d sessions", first.ID, again.ID, f.expireCalls, f.checkoutCalls)
+ }
+ // The session is still open at the provider. The console expires it
+ // itself before it opens anything in another currency.
+ if _, err := fx.srv.startBillingCheckout(ctx, acct, plans.PlanPersonal, "IE", "", "gbp", f.now.Add(time.Minute)); err != nil {
+ t.Fatal(err)
+ }
+ if f.expireCalls != 1 || f.checkoutCalls != 2 {
+ t.Fatalf("after choosing gbp with a session open in eur: %d expiries and %d sessions, want 1 and 2", f.expireCalls, f.checkoutCalls)
+ }
+ second, err := fx.st.billingCheckoutIntent(acct.ID)
+ if err != nil || second.ID == first.ID || second.Currency != "gbp" || first.Currency != "eur" {
+ t.Fatalf("after choosing gbp: first %+v, second %+v, %v", first, second, err)
+ }
+ if got := f.forms[len(f.forms)-1].Get("currency"); got != "gbp" {
+ t.Fatalf("the new session was asked for in %q", got)
+ }
+}
+
+// A completed session is read back before its purchase is recorded. One
+// that is no longer in the intent's currency, or that carries a conversion
+// or Adaptive Pricing, is not bound to the intent and not recorded as a
+// subscription.
+func TestCompletedSessionMustStillBeInTheIntentsCurrency(t *testing.T) {
+ for name, spoil := range map[string]func(f *fakeBillingStripe){
+ "it reads back in another currency": func(f *fakeBillingStripe) { f.sessionCurrency = "eur" },
+ "it reads back with no currency": func(f *fakeBillingStripe) { f.sessionCurrency = "-" },
+ // Everything at the provider agrees with itself, in dollars: the
+ // session, the subscription, its price and its metadata. The
+ // intent the buyer opened was in pounds.
+ "the provider says dollars throughout and the intent says pounds": func(f *fakeBillingStripe) {
+ f.sessionCurrency, f.subscriptionCurrency = "usd", "usd"
+ f.subscriptionPrice = currencyPriceID(plans.PlanPersonal, f.meta["band"], plans.USD)
+ f.meta["currency"] = "usd"
+ },
+ } {
+ t.Run(name, func(t *testing.T) {
+ fx, f, acct := newCurrencyFixture(t)
+ f.customerCountry = "GB"
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "GB", "", "", f.now); err != nil {
+ t.Fatal(err)
+ }
+ intent, _ := fx.st.billingCheckoutIntent(acct.ID)
+ f.mu.Lock()
+ f.sessionStatus, f.subscriptionPrice = "complete", intent.Price
+ spoil(f)
+ f.mu.Unlock()
+ if sub, err := fx.srv.bindBillingCheckout(context.Background(), intent.SessionID); !errors.Is(err, errBillingIdentity) || sub != "" {
+ t.Errorf("binding the session: %q %v, want errBillingIdentity", sub, err)
+ }
+ if err := fx.srv.reconcileBillingSubscription(context.Background(), "sub_paid", "", f.now); !errors.Is(err, errBillingIdentity) {
+ t.Errorf("recording the subscription: %v, want errBillingIdentity", err)
+ }
+ if state, _ := fx.st.billingAccount(acct.ID); state.Subscription != "" {
+ t.Errorf("the purchase was recorded: %+v", state)
+ }
+ })
+ }
+}
+
+// An intent is opened only on price terms that are whole: a book with a
+// currency it sells in, or no book and EUR.
+func TestIntentNeedsWholePriceTerms(t *testing.T) {
+ fx, _, acct := newCurrencyFixture(t)
+ base := billingIntent{AccountID: acct.ID, Plan: plans.PlanPersonal, Terms: plans.CurrentTermsVersion, Band: plans.BandA, Price: "price_x", Quantity: 1, Country: "IE", Members: 1}
+ for name, edit := range map[string]func(i *billingIntent){
+ "a book and no currency": func(i *billingIntent) { i.PriceBook = plans.CurrentPriceBook },
+ "a book and a currency it lacks": func(i *billingIntent) { i.PriceBook, i.Currency = plans.CurrentPriceBook, "jpy" },
+ "a book nobody has": func(i *billingIntent) { i.PriceBook, i.Currency = "2031-01-01-v9", "eur" },
+ "no book and dollars": func(i *billingIntent) { i.Currency = "usd" },
+ "terms nobody has": func(i *billingIntent) {
+ i.Terms, i.PriceBook, i.Currency = "2020-01-01-v0", plans.CurrentPriceBook, "eur"
+ },
+ } {
+ want := base
+ edit(&want)
+ if got, err := fx.st.beginBillingCheckout(want, time.Now()); err == nil {
+ t.Errorf("%s: opened %+v", name, got)
+ }
+ if _, err := fx.st.billingCheckoutIntent(acct.ID); err == nil {
+ t.Fatalf("%s: an intent row exists", name)
+ }
+ }
+ want := base
+ want.PriceBook, want.Currency = plans.CurrentPriceBook, "gbp"
+ if got, err := fx.st.beginBillingCheckout(want, time.Now()); err != nil || got.Currency != "gbp" || got.PriceBook != plans.CurrentPriceBook {
+ t.Fatalf("whole terms: %+v %v", got, err)
+ }
+}
+
+// buyIn opens a checkout in a currency, completes it at the fake provider
+// and reconciles the subscription, as a paid purchase goes.
+func buyIn(t *testing.T, country, currency string) (*consoleFixture, *fakeBillingStripe, *Account, billingIntent) {
+ t.Helper()
+ fx, f, acct := newCurrencyFixture(t)
+ f.customerCountry = country
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, country, "", currency, f.now); err != nil {
+ t.Fatal(err)
+ }
+ intent, err := fx.st.billingCheckoutIntent(acct.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.mu.Lock()
+ f.sessionStatus, f.subscriptionPrice = "complete", intent.Price
+ f.mu.Unlock()
+ if err := fx.srv.reconcileBillingSubscription(context.Background(), "sub_paid", "", f.now); err != nil {
+ t.Fatalf("reconcile: %v", err)
+ }
+ return fx, f, acct, intent
+}
+
+// A purchase is recorded with its currency and its book, on the account
+// and on the assignment its usage will be priced from. After that the
+// subscription is in that currency for good: a reading of it that names
+// another, however consistently, is refused and changes nothing.
+func TestSubscriptionCurrencyIsRecordedAndNeverChanges(t *testing.T) {
+ fx, f, acct, intent := buyIn(t, "GB", "")
+ state, err := fx.st.billingAccount(acct.ID)
+ if err != nil || state.Status != "active" || state.Currency != "gbp" || state.PriceBook != plans.CurrentPriceBook {
+ t.Fatalf("recorded state %+v %v", state, err)
+ }
+ rows, err := fx.st.billingInvoiceHistory(acct.ID)
+ if err != nil || len(rows) != 1 || rows[0].Currency != plans.GBP || rows[0].PriceBook != plans.CurrentPriceBook || rows[0].PriceTerms() != bookTerms(plans.GBP) {
+ t.Fatalf("assignment history %+v %v", rows, err)
+ }
+ usdPrice := currencyPriceID(plans.PlanPersonal, intent.Band, plans.USD)
+ for name, tamper := range map[string]func(){
+ "the subscription reports another currency": func() { f.subscriptionCurrency = "eur" },
+ "its metadata names another currency": func() { f.meta["currency"] = "usd" },
+ "its metadata names a book nobody has": func() { f.meta["price_book"] = "2031-01-01-v9" },
+ "its metadata names no book or currency": func() { delete(f.meta, "price_book"); delete(f.meta, "currency") },
+ "it is another currency through and through": func() {
+ f.meta["currency"], f.subscriptionPrice, f.subscriptionCurrency = "usd", usdPrice, "usd"
+ },
+ "it is a legacy subscription through and through": func() {
+ delete(f.meta, "price_book")
+ delete(f.meta, "currency")
+ f.subscriptionPrice, f.subscriptionCurrency = "price_personal", "eur"
+ },
+ } {
+ t.Run(name, func(t *testing.T) {
+ f.mu.Lock()
+ keepMeta := map[string]string{}
+ for k, v := range f.meta {
+ keepMeta[k] = v
+ }
+ keepPrice, keepCurrency := f.subscriptionPrice, f.subscriptionCurrency
+ f.subscriptionStatus = "past_due" // a real change, so a reading that passed would be written
+ tamper()
+ f.mu.Unlock()
+ err := fx.srv.reconcileBillingSubscription(context.Background(), "sub_paid", "", f.now.Add(time.Minute))
+ if !errors.Is(err, errBillingIdentity) {
+ t.Errorf("reconcile: %v, want errBillingIdentity", err)
+ }
+ after, _ := fx.st.billingAccount(acct.ID)
+ if after != state {
+ t.Errorf("the account changed: %+v, was %+v", after, state)
+ }
+ if rows, _ := fx.st.billingInvoiceHistory(acct.ID); len(rows) != 1 {
+ t.Errorf("%d assignments after a refused reading", len(rows))
+ }
+ f.mu.Lock()
+ f.meta, f.subscriptionPrice, f.subscriptionCurrency, f.subscriptionStatus = keepMeta, keepPrice, keepCurrency, "active"
+ f.mu.Unlock()
+ })
+ }
+ // An honest change to the same subscription is still recorded, in the
+ // same currency.
+ f.mu.Lock()
+ f.subscriptionStatus = "past_due"
+ f.mu.Unlock()
+ if err := fx.srv.reconcileBillingSubscription(context.Background(), "sub_paid", "", f.now.Add(time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ if after, _ := fx.st.billingAccount(acct.ID); after.Status != "past_due" || after.Currency != "gbp" || after.PriceBook != plans.CurrentPriceBook {
+ t.Fatalf("after an honest change: %+v", after)
+ }
+}
+
+// A subscription bought before price books says nothing about a currency.
+// A console that now sells from a book still recognises it, by the legacy
+// price key, as EUR, and records it with no currency, which is what such a
+// row is. It is not given the book's currency, and one that turns out to
+// be in another currency is refused.
+func TestLegacySubscriptionStaysEURUnderAConsoleWithABook(t *testing.T) {
+ fx, f, acct := newBillingFixture(t)
+ ctx := context.Background()
+ if _, err := fx.srv.startBillingCheckout(ctx, acct, plans.PlanPersonal, "IE", "", "", f.now); err != nil {
+ t.Fatal(err)
+ }
+ f.mu.Lock()
+ f.sessionStatus = "complete"
+ f.mu.Unlock()
+ if err := fx.srv.reconcileBillingSubscription(ctx, "sub_paid", "", f.now); err != nil {
+ t.Fatal(err)
+ }
+ before, _ := fx.st.billingAccount(acct.ID)
+ if before.Status != "active" || before.Currency != "" || before.PriceBook != "" || before.priceTerms().Money() != plans.EUR {
+ t.Fatalf("the legacy purchase: %+v", before)
+ }
+ // The console is upgraded and now sells from the book.
+ fx.srv.billing.cfg.PriceBook = plans.CurrentPriceBook
+ f.mu.Lock()
+ f.subscriptionStatus = "past_due"
+ f.mu.Unlock()
+ if err := fx.srv.reconcileBillingSubscription(ctx, "sub_paid", "", f.now.Add(time.Minute)); err != nil {
+ t.Fatalf("a legacy subscription under a console with a book: %v", err)
+ }
+ after, _ := fx.st.billingAccount(acct.ID)
+ if after.Status != "past_due" || after.Currency != "" || after.PriceBook != "" {
+ t.Fatalf("after the upgrade: %+v", after)
+ }
+ f.mu.Lock()
+ f.subscriptionCurrency, f.subscriptionStatus = "usd", "active"
+ f.mu.Unlock()
+ if err := fx.srv.reconcileBillingSubscription(ctx, "sub_paid", "", f.now.Add(2*time.Minute)); !errors.Is(err, errBillingIdentity) {
+ t.Fatalf("a legacy subscription the provider reports in usd: %v", err)
+ }
+}
+
+// An account's currency can change only with a new subscription, after the
+// old one has ended. The two are then different rows of its history, and a
+// month they both paid for is priced in neither currency.
+func TestCurrencyChangesOnlyWithANewSubscriptionAndNeverInsideAMonth(t *testing.T) {
+ fx, _, acct := newCurrencyFixture(t)
+ st := fx.st
+ at := func(day int) time.Time { return time.Date(2026, 9, day, 0, 0, 0, 0, time.UTC) }
+ state := func(sub, status string, c plans.Currency, start time.Time) billingState {
+ return billingState{AccountID: acct.ID, Customer: "cus_one", Subscription: sub, Status: status, Plan: plans.PlanPersonal, Terms: plans.CurrentTermsVersion, PriceBook: plans.CurrentPriceBook, Currency: string(c), Band: plans.BandA, Quantity: 1, PeriodStart: start.Unix(), PeriodEnd: start.AddDate(1, 0, 0).Unix()}
+ }
+ if err := st.applyBillingState(state("sub_a", "active", plans.GBP, at(1)), 0, "e1", at(1)); err != nil {
+ t.Fatal(err)
+ }
+ // The same subscription, live, in dollars: refused.
+ if err := st.applyBillingState(state("sub_a", "active", plans.USD, at(1)), 1, "e2", at(5)); !errors.Is(err, errBillingIdentity) {
+ t.Fatalf("the same subscription in another currency: %v", err)
+ }
+ // Another subscription while the first is live: refused, as it always was.
+ if err := st.applyBillingState(state("sub_b", "active", plans.USD, at(5)), 1, "e3", at(5)); err == nil {
+ t.Fatal("a second subscription was recorded beside a live one")
+ }
+ // A book with no currency, and a currency the book does not sell in.
+ for _, c := range []plans.Currency{"", "jpy"} {
+ if err := st.applyBillingState(state("sub_a", "past_due", c, at(1)), 1, "", at(6)); err == nil {
+ t.Fatalf("currency %q was recorded", c)
+ }
+ }
+ if err := st.applyBillingState(state("sub_a", "canceled", plans.GBP, at(1)), 1, "e4", at(10)); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.applyBillingState(state("sub_b", "active", plans.USD, at(10)), 2, "e5", at(10)); err != nil {
+ t.Fatalf("a new subscription in another currency after the old one ended: %v", err)
+ }
+ now, _ := st.billingAccount(acct.ID)
+ if now.Subscription != "sub_b" || now.Currency != "usd" {
+ t.Fatalf("the account: %+v", now)
+ }
+ rows, err := st.billingInvoiceHistory(acct.ID)
+ if err != nil || len(rows) != 3 || rows[0].Currency != plans.GBP || rows[1].Currency != plans.GBP || rows[2].Currency != plans.USD {
+ t.Fatalf("history: %+v %v", rows, err)
+ }
+ history := make([]plans.Assignment, len(rows))
+ for i, row := range rows {
+ history[i] = row.Assignment
+ }
+ if _, err := plans.AllowanceForMonth("2026-09", history); !errors.Is(err, plans.ErrAssignmentHistory) {
+ t.Fatalf("a month paid for in pounds and then in dollars: %v, want it refused", err)
+ }
+ statement := usage.Statement{V: 1, Account: fx.sqid, Month: "2026-09", Meters: map[string]float64{usage.MeterInstanceHours: 1000}, DaysInMonth: 30, Closed: true}
+ if calc, err := priceBillingCalculation(statement, history); err == nil {
+ t.Fatalf("the month was priced: %+v", calc)
+ }
+ if after, err := plans.AllowanceForMonth("2026-10", history); err != nil || after.Currency != plans.USD {
+ t.Fatalf("the month after, in dollars throughout: %+v %v", after, err)
+ }
+}
+
+// billingCalculationV1 is the calculation as it was written before version
+// 2 existed: the same fields in the same order, with its money by value.
+// A legacy month must still marshal to exactly these bytes.
+type billingCalculationV1 struct {
+ StatementObject usage.ObjectEvidence `json:"statement_object"`
+ V int `json:"v"`
+ Arithmetic string `json:"arithmetic"`
+ Currency string `json:"currency"`
+ Statement usage.Statement `json:"statement"`
+ StatementSHA256 string `json:"statement_sha256"`
+ Allowance plans.MonthAllowance `json:"allowance"`
+ Usage plans.Resources `json:"usage"`
+ Overage plans.OverageResult `json:"overage"`
+ Invoice plans.InvoiceResult `json:"invoice"`
+ Lines []plans.OverageLine `json:"lines"`
+ TaxCode string `json:"tax_code"`
+ CountedDays []string `json:"counted_days,omitempty"`
+}
+
+func monthHistory(terms plans.PriceTerms, house bool) []plans.Assignment {
+ effective := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC)
+ return []plans.Assignment{{EffectiveAt: effective, Revision: 1, TermsVersion: terms.TermsVersion, Plan: plans.PlanPersonal, Band: plans.BandA, Quantity: 1, House: house, Status: "active", PaidThrough: effective.AddDate(1, 0, 0), PriceBook: terms.PriceBook, Currency: terms.Currency}}
+}
+
+// A month is priced in the currency its subscription was bought in. From a
+// book that is a version 2 calculation, whose every amount is in that one
+// currency under names that claim no other. From before books it is a
+// version 1 calculation, EUR, byte for byte what it always was.
+func TestMonthIsPricedInTheCurrencyItWasBoughtIn(t *testing.T) {
+ statement := usage.Statement{V: 1, Account: "bab", Month: "2026-09", Meters: map[string]float64{usage.MeterInstanceHours: 1000, usage.MeterStorageGBMonth: 40}, DaysInMonth: 30, Closed: true}
+ legacy, err := priceBillingCalculation(statement, monthHistory(plans.LegacyPriceTerms(plans.CurrentTermsVersion), false))
+ if err != nil || !legacy.wellFormed() || legacy.V != 1 || legacy.Currency != "eur" || legacy.Charges != nil || legacy.Total != nil || legacy.PriceBook != "" {
+ t.Fatalf("legacy month: %+v %v", legacy, err)
+ }
+ legacy.TaxCode = "txcd_test"
+ got, err := json.Marshal(legacy)
+ if err != nil {
+ t.Fatal(err)
+ }
+ was, err := json.Marshal(billingCalculationV1{StatementObject: legacy.StatementObject, V: 1, Arithmetic: legacy.Arithmetic, Currency: "eur", Statement: legacy.Statement, StatementSHA256: legacy.StatementSHA256,
+ Allowance: legacy.Allowance, Usage: legacy.Usage, Overage: *legacy.Overage, Invoice: *legacy.Invoice, Lines: legacy.Lines, TaxCode: "txcd_test", CountedDays: legacy.CountedDays})
+ if err != nil || string(got) != string(was) {
+ t.Fatalf("a legacy month marshals as\n%s\nand used to marshal as\n%s", got, was)
+ }
+ var back billingCalculation
+ if err := json.Unmarshal(got, &back); err != nil || !back.wellFormed() || back.totalMinor() != legacy.Invoice.TotalCents || back.waived() != legacy.Invoice.Waived {
+ t.Fatalf("a legacy month read back: %+v %v", back, err)
+ }
+ for _, c := range []plans.Currency{plans.USD, plans.EUR, plans.GBP} {
+ calc, err := priceBillingCalculation(statement, monthHistory(bookTerms(c), false))
+ if err != nil || !calc.wellFormed() || calc.V != 2 || calc.Currency != string(c) || calc.PriceBook != plans.CurrentPriceBook || calc.Overage != nil || calc.Invoice != nil {
+ t.Fatalf("%s month: %+v %v", c, calc, err)
+ }
+ if calc.Usage != legacy.Usage || calc.Allowance.Resources != legacy.Allowance.Resources {
+ t.Errorf("%s: usage or allowance differs from the legacy month's", c)
+ }
+ want, err := plans.OverageFor(bookTerms(c), plans.PlanPersonal, calc.Usage, calc.Allowance.Resources)
+ if err != nil || *calc.Charges != *want || calc.Charges.Currency != c || calc.Total.Currency != c {
+ t.Errorf("%s charges %+v, want %+v (%v)", c, calc.Charges, want, err)
+ }
+ var sum int64
+ for _, l := range calc.Lines {
+ sum += l.AmountCents
+ }
+ if calc.waived() || sum != calc.totalMinor() || calc.totalMinor() <= 0 {
+ t.Errorf("%s: total %d, lines sum to %d", c, calc.totalMinor(), sum)
+ }
+ raw, _ := json.Marshal(calc)
+ for _, absent := range []string{"MicroEUR", `"overage"`, `"invoice"`, "TotalCents"} {
+ if strings.Contains(string(raw), absent) {
+ t.Errorf("a %s month's calculation contains %s: %s", c, absent, raw)
+ }
+ }
+ for _, present := range []string{`"v":2`, `"currency":"` + string(c) + `"`, `"price_book":"` + plans.CurrentPriceBook + `"`, `"charges"`, `"total"`} {
+ if !strings.Contains(string(raw), present) {
+ t.Errorf("a %s month's calculation lacks %s: %s", c, present, raw)
+ }
+ }
+ var back billingCalculation
+ if err := json.Unmarshal(raw, &back); err != nil || !back.wellFormed() || back.totalMinor() != calc.totalMinor() {
+ t.Errorf("a %s month read back: %+v %v", c, back, err)
+ }
+ // EUR from the book is the legacy month's money.
+ if c == plans.EUR && (calc.totalMinor() != legacy.Invoice.TotalCents || calc.Charges.TotalMicro != legacy.Overage.TotalMicroEUR) {
+ t.Errorf("EUR from the book: %d minor, legacy %d", calc.totalMinor(), legacy.Invoice.TotalCents)
+ }
+ }
+ // A house month is waived in any currency.
+ house, err := priceBillingCalculation(statement, monthHistory(bookTerms(plans.GBP), true))
+ if err != nil || !house.waived() || len(house.Lines) != 0 || house.totalMinor() != 0 {
+ t.Fatalf("a house month in gbp: %+v %v", house, err)
+ }
+}
+
+// A calculation read back from storage is acted on only if it is whole for
+// its version and names one currency throughout.
+func TestCalculationWellFormed(t *testing.T) {
+ statement := usage.Statement{V: 1, Account: "bab", Month: "2026-09", Meters: map[string]float64{usage.MeterInstanceHours: 1000}, DaysInMonth: 30, Closed: true}
+ fresh := func(t *testing.T, terms plans.PriceTerms) billingCalculation {
+ calc, err := priceBillingCalculation(statement, monthHistory(terms, false))
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, _ := json.Marshal(calc)
+ var copied billingCalculation
+ if err := json.Unmarshal(raw, &copied); err != nil {
+ t.Fatal(err)
+ }
+ return copied
+ }
+ legacy, gbp := plans.LegacyPriceTerms(plans.CurrentTermsVersion), bookTerms(plans.GBP)
+ for name, tc := range map[string]struct {
+ terms plans.PriceTerms
+ spoil func(c *billingCalculation)
+ }{
+ "v1 said to be in dollars": {legacy, func(c *billingCalculation) { c.Currency = "usd" }},
+ "v1 with a book": {legacy, func(c *billingCalculation) { c.PriceBook = plans.CurrentPriceBook }},
+ "v1 with no invoice": {legacy, func(c *billingCalculation) { c.Invoice = nil }},
+ "v1 whose allowance names a currency": {legacy, func(c *billingCalculation) { c.Allowance.Currency = plans.GBP }},
+ "v1 carrying v2 money": {legacy, func(c *billingCalculation) { c.Total = &plans.Invoice{Currency: plans.EUR} }},
+ "v2 said to be in another currency": {gbp, func(c *billingCalculation) { c.Currency = "eur" }},
+ "v2 whose total is in another": {gbp, func(c *billingCalculation) { c.Total.Currency = plans.USD }},
+ "v2 whose charges are in another": {gbp, func(c *billingCalculation) { c.Charges.Currency = plans.USD }},
+ "v2 whose allowance is in another": {gbp, func(c *billingCalculation) { c.Allowance.Currency = plans.USD }},
+ "v2 from another book than it says": {gbp, func(c *billingCalculation) { c.PriceBook = "2031-01-01-v9" }},
+ "v2 with no charges": {gbp, func(c *billingCalculation) { c.Charges = nil }},
+ "v2 with no total": {gbp, func(c *billingCalculation) { c.Total = nil }},
+ "v2 carrying v1 money": {gbp, func(c *billingCalculation) { c.Invoice = &plans.InvoiceResult{} }},
+ "v2 relabelled as v1": {gbp, func(c *billingCalculation) { c.V = 1 }},
+ "v1 relabelled as v2": {legacy, func(c *billingCalculation) { c.V = 2 }},
+ "a version nobody wrote": {gbp, func(c *billingCalculation) { c.V = 3 }},
+ "v2 in a currency the book lacks": {gbp, func(c *billingCalculation) {
+ c.Currency, c.Allowance.Currency, c.Charges.Currency, c.Total.Currency = "jpy", "jpy", "jpy", "jpy"
+ }},
+ } {
+ calc := fresh(t, tc.terms)
+ if !calc.wellFormed() {
+ t.Fatalf("%s: fixture: the calculation is not well formed before it is spoiled", name)
+ }
+ tc.spoil(&calc)
+ if calc.wellFormed() {
+ t.Errorf("%s: accepted", name)
+ }
+ }
+}
+
+// The overage invoice for a month bought in pounds is created, lined and
+// verified in pounds, end to end through the engine. A provider that
+// answers in another currency at any step stops it, and a subscription
+// the provider reports in another currency stops it before anything is
+// written.
+func TestInvoiceEngineInvoicesInTheSubscriptionsCurrency(t *testing.T) {
+ inPounds := func(t *testing.T) *invoiceFixture {
+ ix := newInvoiceFixture(t)
+ // The fixture's account bought in pounds from the current book.
+ for _, table := range []string{"billing_accounts", "billing_assignments"} {
+ if _, err := ix.st.db.Exec(`UPDATE `+table+` SET currency='gbp', price_book=? WHERE account_id=?`, plans.CurrentPriceBook, ix.acct.ID); err != nil {
+ t.Fatal(err)
+ }
+ }
+ ix.fake.subscriptionCurrency = "gbp"
+ return ix
+ }
+ t.Run("in pounds throughout", func(t *testing.T) {
+ ix := inPounds(t)
+ want := ix.expectedCalc(t)
+ if want.V != 2 || want.Currency != "gbp" || want.waived() {
+ t.Fatalf("fixture: the month's calculation: %+v", want)
+ }
+ if err := ix.run(t); err != nil {
+ t.Fatal(err)
+ }
+ rec := ix.month(t)
+ var frozen billingCalculation
+ if err := json.Unmarshal(rec.Calculation, &frozen); err != nil || rec.State != "complete" || !frozen.wellFormed() || frozen.Currency != "gbp" || frozen.totalMinor() != want.totalMinor() {
+ t.Fatalf("month %+v, calculation %+v %v", rec, frozen, err)
+ }
+ if len(ix.fake.currencies) < 2 {
+ t.Fatalf("requests that named a currency: %v", ix.fake.currencies)
+ }
+ for kind, c := range ix.fake.currencies {
+ if c != "gbp" {
+ t.Errorf("the %s request was made in %q", kind, c)
+ }
+ }
+ inv := ix.fake.invoices[rec.InvoiceID]
+ var sum int64
+ for _, l := range inv.lines {
+ sum += l.amount
+ if l.currency != "gbp" {
+ t.Errorf("a line was created in %q", l.currency)
+ }
+ }
+ if inv.currency != "gbp" || sum != want.totalMinor() {
+ t.Fatalf("the invoice: %s, %d pence; want gbp, %d", inv.currency, sum, want.totalMinor())
+ }
+ })
+ for name, spoil := range map[string]func(ix *invoiceFixture){
+ "the draft comes back in euros": func(ix *invoiceFixture) { ix.fake.reportInvoiceCurrency = "eur" },
+ "an item comes back in euros": func(ix *invoiceFixture) { ix.fake.reportItemCurrency = "eur" },
+ "a line reads back in euros": func(ix *invoiceFixture) { ix.fake.reportLineCurrency = "eur" },
+ } {
+ t.Run(name, func(t *testing.T) {
+ ix := inPounds(t)
+ spoil(ix)
+ err := ix.run(t)
+ rec := ix.month(t)
+ if rec.State == "complete" || rec.State == "paying" || rec.State == "open" {
+ t.Fatalf("the month reached %q with a provider answering in another currency (run: %v)", rec.State, err)
+ }
+ if _, mutations, _ := ix.fake.counts(); mutations["finalize"] != 0 || mutations["pay"] != 0 {
+ t.Fatalf("the invoice was finalized or paid: %v", mutations)
+ }
+ })
+ }
+ t.Run("the subscription is reported in euros", func(t *testing.T) {
+ ix := inPounds(t)
+ ix.fake.subscriptionCurrency = "eur"
+ if err := ix.run(t); !errors.Is(err, errBillingIdentity) {
+ t.Fatalf("run: %v, want errBillingIdentity", err)
+ }
+ if _, mutations, _ := ix.fake.counts(); len(mutations) != 0 {
+ t.Fatalf("something was written: %v", mutations)
+ }
+ })
+ t.Run("the subscription reports no currency", func(t *testing.T) {
+ ix := inPounds(t)
+ ix.fake.subscriptionCurrency = ""
+ if err := ix.run(t); !errors.Is(err, errBillingIdentity) {
+ t.Fatalf("run: %v, want errBillingIdentity", err)
+ }
+ })
+ // A month frozen in pounds whose assignment row has since been altered
+ // to another currency is not invoiced in either.
+ t.Run("the recorded assignment no longer matches the frozen month", func(t *testing.T) {
+ ix := inPounds(t)
+ ix.fake.dropRequest["draft"] = true
+ _ = ix.run(t)
+ if rec := ix.month(t); rec.Digest == "" {
+ t.Fatal("fixture: the month was not frozen")
+ }
+ // Only the recorded assignment changes. The provider still reports
+ // the subscription in pounds, so nothing but the comparison of the
+ // frozen month with the record can notice.
+ if _, err := ix.st.db.Exec(`UPDATE billing_assignments SET currency='usd' WHERE account_id=?`, ix.acct.ID); err != nil {
+ t.Fatal(err)
+ }
+ ix.now = ix.now.Add(time.Minute)
+ _ = ix.run(t)
+ if rec := ix.month(t); rec.State == "complete" || rec.InvoiceID != "" {
+ t.Fatalf("a month frozen in pounds went ahead after its assignment changed currency: %+v", rec)
+ }
+ if _, mutations, _ := ix.fake.counts(); len(mutations) != 0 {
+ t.Fatalf("something was written: %v", mutations)
+ }
+ })
+}
+
+// A request's currency is part of its hash. A legacy month's requests
+// hash exactly as they did before a currency was a parameter, so an
+// operation journaled by an earlier build is recognised; they are still
+// sent naming EUR.
+func TestInvoiceRequestHashAndCurrency(t *testing.T) {
+ type draftBefore struct {
+ Customer, Subscription, SubscriptionRef, Account, Month, CalculationDigest string
+ }
+ type itemBefore struct {
+ Customer, Invoice, Meter, TaxCode, Description, CalculationDigest string
+ AmountCents, PeriodStart, PeriodEnd int64
+ }
+ run := func(v int, currency string) *billingInvoiceRun {
+ return &billingInvoiceRun{
+ acct: &Account{Sqid: "bab"}, month: "2026-09", start: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), end: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC),
+ rec: billingMonthRecord{Customer: "cus_hist", Subscription: "sub_hist", InvoiceID: "in_usage", Digest: strings.Repeat("a", 64), LinkSubscription: true},
+ calc: billingCalculation{V: v, Currency: currency, TaxCode: "txcd_test"},
+ }
+ }
+ legacy := run(1, "eur")
+ draft := legacy.draftParams()
+ line, err := legacy.lineParams(plans.OverageLine{Meter: usage.MeterInstanceHours, AmountCents: 915})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if draft.Currency != "eur" || line.Currency != "eur" {
+ t.Fatalf("a legacy month's requests name %q and %q", draft.Currency, line.Currency)
+ }
+ if got, was := legacy.requestHash("draft", draft), billingRequestHash("draft", draftBefore{draft.Customer, draft.Subscription, draft.SubscriptionRef, draft.Account, draft.Month, draft.CalculationDigest}); got != was {
+ t.Errorf("a legacy draft hashes as %s and used to hash as %s", got, was)
+ }
+ if got, was := legacy.requestHash("line-x", line), billingRequestHash("line-x", itemBefore{line.Customer, line.Invoice, line.Meter, line.TaxCode, line.Description, line.CalculationDigest, line.AmountCents, line.PeriodStart, line.PeriodEnd}); got != was {
+ t.Errorf("a legacy line hashes as %s and used to hash as %s", got, was)
+ }
+ hashes := map[string]string{"legacy": legacy.requestHash("draft", draft)}
+ for _, c := range []string{"usd", "eur", "gbp"} {
+ r := run(2, c)
+ p := r.draftParams()
+ if p.Currency != c {
+ t.Fatalf("a %s month's draft names %q", c, p.Currency)
+ }
+ h := r.requestHash("draft", p)
+ for other, seen := range hashes {
+ if seen == h {
+ t.Errorf("a %s draft hashes like a %s one", c, other)
+ }
+ }
+ hashes[c] = h
+ }
+}
+
+// The quantity worker reads the price for the account's own stored book and
+// currency, and a subscription the provider reports in another currency is
+// not one it acts on.
+func TestQuantityWorkerChecksTheStoredCurrency(t *testing.T) {
+ fx, f, acct, _ := buyIn(t, "GB", "")
+ state, _ := fx.st.billingAccount(acct.ID)
+ if _, ok, err := fx.srv.quantityProviderItem(context.Background(), acct, state); err != nil || !ok {
+ t.Fatalf("the account's own subscription: ok=%v err=%v", ok, err)
+ }
+ f.mu.Lock()
+ f.subscriptionCurrency = "eur"
+ f.mu.Unlock()
+ if _, _, err := fx.srv.quantityProviderItem(context.Background(), acct, state); !errors.Is(err, errBillingIdentity) {
+ t.Fatalf("a subscription reported in another currency: %v", err)
+ }
+ f.mu.Lock()
+ f.subscriptionCurrency, f.subscriptionPrice = "", currencyPriceID(plans.PlanPersonal, state.Band, plans.EUR)
+ f.mu.Unlock()
+ if _, _, err := fx.srv.quantityProviderItem(context.Background(), acct, state); !errors.Is(err, errBillingIdentity) {
+ t.Fatalf("a subscription on another currency's price: %v", err)
+ }
+}
+
+// A valid ?currency= on a GET is kept in a short-lived cookie so it
+// survives the sign-in redirects. It is a suggestion: an invalid value is
+// dropped, never stored, and a cookie somebody forged is not believed.
+func TestCurrencySuggestionCookie(t *testing.T) {
+ fx, _, _ := newCurrencyFixture(t)
+ cookie := func(method, target string) *http.Cookie {
+ w := httptest.NewRecorder()
+ fx.srv.ServeHTTP(w, httptest.NewRequest(method, target, nil))
+ for _, c := range w.Result().Cookies() {
+ if c.Name == currencyCookie {
+ return c
+ }
+ }
+ return nil
+ }
+ for _, target := range []string{"/?currency=gbp", "/auth/signin?currency=gbp", fx.path("/billing") + "?currency=gbp&x=1"} {
+ c := cookie(http.MethodGet, target)
+ if c == nil || c.Value != "gbp" || c.MaxAge != int(currencyCookieTTL.Seconds()) || !c.HttpOnly || c.SameSite != http.SameSiteLaxMode || c.Path != "/" {
+ t.Errorf("GET %s: cookie %+v", target, c)
+ }
+ }
+ for _, target := range []string{"/", "/?currency=", "/?currency=jpy", "/?currency=GBP", "/?currency=gbp&currency=usd", "/?currency=gbp%20"} {
+ if c := cookie(http.MethodGet, target); c != nil {
+ t.Errorf("GET %s set %+v", target, c)
+ }
+ }
+ if c := cookie(http.MethodPost, "/?currency=gbp"); c != nil {
+ t.Errorf("a POST set %+v", c)
+ }
+ suggest := func(srv *Server, value string) string {
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ if value != "-" {
+ r.AddCookie(&http.Cookie{Name: currencyCookie, Value: value})
+ }
+ return srv.suggestedCurrency(r)
+ }
+ for value, want := range map[string]string{"gbp": "gbp", "usd": "usd", "eur": "eur", "jpy": "", "GBP": "", "": "", "-": ""} {
+ if got := suggest(fx.srv, value); got != want {
+ t.Errorf("cookie %q suggests %q, want %q", value, got, want)
+ }
+ }
+ if got := strings.Join(fx.srv.billingCurrencies(), ","); got != "usd,eur,gbp" {
+ t.Errorf("currencies offered: %s", got)
+ }
+ // A console selling the old way offers and remembers EUR alone, and
+ // one with billing off offers and remembers nothing.
+ old, _, _ := newBillingFixture(t)
+ if got := strings.Join(old.srv.billingCurrencies(), ","); got != "eur" || suggest(old.srv, "gbp") != "" || suggest(old.srv, "eur") != "eur" {
+ t.Errorf("a console with no book: offers %q", got)
+ }
+ off := newConsoleFixture(t)
+ if len(off.srv.billingCurrencies()) != 0 || suggest(off.srv, "eur") != "" {
+ t.Error("a console with billing off offers a currency")
+ }
+}
+
+// The columns are added to a database from before them, and every row
+// already there reads as what it is: a purchase with no book, in EUR.
+func TestCurrencyColumnsOnAnExistingDatabase(t *testing.T) {
+ fx, _, acct := newBillingFixture(t)
+ st := fx.st
+ at := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
+ if err := st.applyBillingState(billingState{AccountID: acct.ID, Customer: "cus_one", Subscription: "sub_a", Status: "active", Plan: plans.PlanPersonal, Terms: plans.CurrentTermsVersion, Band: plans.BandA, Quantity: 1, PeriodStart: at.Unix(), PeriodEnd: at.AddDate(1, 0, 0).Unix()}, 0, "e1", at); err != nil {
+ t.Fatal(err)
+ }
+ for _, table := range []string{"billing_accounts", "billing_checkout_intents", "billing_assignments"} {
+ for _, column := range []string{"currency", "price_book"} {
+ if _, err := st.db.Exec(`ALTER TABLE ` + table + ` DROP COLUMN ` + column); err != nil {
+ t.Fatalf("fixture: drop %s.%s: %v", table, column, err)
+ }
+ }
+ }
+ if err := st.migrateBilling(); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.migrateBilling(); err != nil {
+ t.Fatalf("a second migration: %v", err)
+ }
+ state, err := st.billingAccount(acct.ID)
+ if err != nil || state.Subscription != "sub_a" || state.Currency != "" || state.PriceBook != "" || !state.priceTerms().Legacy() || state.priceTerms().Money() != plans.EUR {
+ t.Fatalf("the row from before: %+v %v", state, err)
+ }
+ rows, err := st.billingInvoiceHistory(acct.ID)
+ if err != nil || len(rows) != 1 || rows[0].Currency != "" || rows[0].PriceBook != "" {
+ t.Fatalf("its assignment: %+v %v", rows, err)
+ }
+ // And it is still the same subscription when read again as legacy.
+ next := state
+ next.Status = "past_due"
+ if err := st.applyBillingState(next, state.Revision, "e2", at.AddDate(0, 0, 3)); err != nil {
+ t.Fatalf("a legacy row updated after the migration: %v", err)
+ }
+ // Written with an explicit "eur" and no book, it is stored the one way.
+ next.Status, next.Currency = "active", "eur"
+ if err := st.applyBillingState(next, state.Revision+1, "e3", at.AddDate(0, 0, 4)); err != nil {
+ t.Fatalf("a legacy row written with eur: %v", err)
+ }
+ if after, _ := st.billingAccount(acct.ID); after.Currency != "" || after.Status != "active" {
+ t.Fatalf("stored as %+v", after)
+ }
+}
diff --git a/internal/console/billinginvoice_test.go b/internal/console/billinginvoice_test.go
index 390d7fe72..1f5f9fe35 100644
--- a/internal/console/billinginvoice_test.go
+++ b/internal/console/billinginvoice_test.go
@@ -47,6 +47,14 @@ type fakeInvoiceStripe struct {
listDuplicate bool
hasMoreLines bool
seq int
+ // The fake is in whatever currency each request named, as the
+ // provider is. currencies records what each kind of request named.
+ // The report* fields make it answer in another currency than it was
+ // asked for; subscriptionCurrency is what the subscription reports
+ // ("" reports none, as a subscription from before currencies did).
+ currencies map[string]string
+ reportInvoiceCurrency, reportItemCurrency, reportLineCurrency string
+ subscriptionCurrency string
}
type fakeKeyed struct {
@@ -55,6 +63,7 @@ type fakeKeyed struct {
}
type fakeInvoice struct {
+ currency string
id, customer, subscription, status string
metadata map[string]string
lines []fakeLine
@@ -62,13 +71,21 @@ type fakeInvoice struct {
}
type fakeLine struct {
+ currency string
id, item, meter, digest string
amount int64
}
func newFakeInvoiceStripe(sqid string, now func() time.Time) *fakeInvoiceStripe {
return &fakeInvoiceStripe{sqid: sqid, now: now, merchant: "acct_carloku", customerSqid: sqid, customerTax: "supported", subscriptionStatus: "active", subscriptionCustomer: "cus_hist", taxStatus: "complete",
- invoices: map[string]*fakeInvoice{}, keyed: map[string]fakeKeyed{}, arrivals: map[string]int{}, mutations: map[string]int{}, loseResponse: map[string]bool{}, dropRequest: map[string]bool{}, refuse: map[string]int{}}
+ invoices: map[string]*fakeInvoice{}, keyed: map[string]fakeKeyed{}, arrivals: map[string]int{}, mutations: map[string]int{}, loseResponse: map[string]bool{}, dropRequest: map[string]bool{}, refuse: map[string]int{}, currencies: map[string]string{}}
+}
+
+func either(override, own string) string {
+ if override != "" {
+ return override
+ }
+ return own
}
func (f *fakeInvoiceStripe) invoiceJSON(inv *fakeInvoice, allLines bool) map[string]any {
@@ -79,11 +96,11 @@ func (f *fakeInvoiceStripe) invoiceJSON(inv *fakeInvoice, allLines bool) map[str
if f.hasMoreLines && !allLines && i > 0 {
continue
}
- lines = append(lines, map[string]any{"id": l.id, "invoice_item": l.item, "type": "invoiceitem", "amount": l.amount, "currency": "eur", "discountable": false,
+ lines = append(lines, map[string]any{"id": l.id, "invoice_item": l.item, "type": "invoiceitem", "amount": l.amount, "currency": either(f.reportLineCurrency, l.currency), "discountable": false,
"metadata": map[string]string{"meter": l.meter, "calculation_digest": l.digest}, "period": map[string]int64{"start": 1, "end": 2}})
}
if f.extraLine && inv.status == "draft" {
- lines = append(lines, map[string]any{"id": "il_extra", "invoice_item": "ii_extra", "type": "invoiceitem", "amount": 100, "currency": "eur", "discountable": false, "metadata": map[string]string{}})
+ lines = append(lines, map[string]any{"id": "il_extra", "invoice_item": "ii_extra", "type": "invoiceitem", "amount": 100, "currency": inv.currency, "discountable": false, "metadata": map[string]string{}})
}
total := subtotal + subtotal*23/100
due, paid := int64(0), int64(0)
@@ -98,7 +115,7 @@ func (f *fakeInvoiceStripe) invoiceJSON(inv *fakeInvoice, allLines bool) map[str
if inv.subscription != "" {
sub = inv.subscription
}
- return map[string]any{"id": inv.id, "customer": inv.customer, "subscription": sub, "livemode": false, "status": inv.status, "currency": "eur", "auto_advance": false,
+ return map[string]any{"id": inv.id, "customer": inv.customer, "subscription": sub, "livemode": false, "status": inv.status, "currency": either(f.reportInvoiceCurrency, inv.currency), "auto_advance": false,
"collection_method": "charge_automatically", "created": inv.created, "subtotal": subtotal, "total": total, "amount_due": due, "amount_paid": paid, "discounts": []any{},
"metadata": inv.metadata, "automatic_tax": map[string]any{"enabled": true, "status": taxStatus}, "lines": map[string]any{"data": lines, "has_more": f.hasMoreLines && !allLines && len(inv.lines) > 1}}
}
@@ -155,6 +172,9 @@ func (f *fakeInvoiceStripe) serve(w http.ResponseWriter, r *http.Request) {
kind = "pay"
}
f.arrivals[kind]++
+ if c := r.PostForm.Get("currency"); c != "" {
+ f.currencies[kind] = c
+ }
if prior, ok := f.keyed[key]; ok {
w.WriteHeader(prior.status)
w.Write(prior.body)
@@ -164,7 +184,7 @@ func (f *fakeInvoiceStripe) serve(w http.ResponseWriter, r *http.Request) {
case path == "/v1/invoices":
f.fault(kind, w, func() any {
f.seq++
- inv := &fakeInvoice{id: "in_" + strconv.Itoa(f.seq), customer: r.PostForm.Get("customer"), subscription: r.PostForm.Get("subscription"), status: "draft", created: f.now().Unix(), metadata: map[string]string{}}
+ inv := &fakeInvoice{currency: r.PostForm.Get("currency"), id: "in_" + strconv.Itoa(f.seq), customer: r.PostForm.Get("customer"), subscription: r.PostForm.Get("subscription"), status: "draft", created: f.now().Unix(), metadata: map[string]string{}}
for k, v := range r.PostForm {
if strings.HasPrefix(k, "metadata[") {
inv.metadata[strings.TrimSuffix(strings.TrimPrefix(k, "metadata["), "]")] = v[0]
@@ -184,9 +204,9 @@ func (f *fakeInvoiceStripe) serve(w http.ResponseWriter, r *http.Request) {
f.fault(kind, w, func() any {
f.seq++
amount, _ := strconv.ParseInt(r.PostForm.Get("amount"), 10, 64)
- line := fakeLine{id: "il_" + strconv.Itoa(f.seq), item: "ii_" + strconv.Itoa(f.seq), meter: r.PostForm.Get("metadata[meter]"), digest: r.PostForm.Get("metadata[calculation_digest]"), amount: amount}
+ line := fakeLine{currency: r.PostForm.Get("currency"), id: "il_" + strconv.Itoa(f.seq), item: "ii_" + strconv.Itoa(f.seq), meter: r.PostForm.Get("metadata[meter]"), digest: r.PostForm.Get("metadata[calculation_digest]"), amount: amount}
inv.lines = append(inv.lines, line)
- return map[string]any{"id": line.item, "invoice": inv.id, "customer": inv.customer, "livemode": false, "amount": amount, "currency": "eur", "discountable": r.PostForm.Get("discountable") != "false", "metadata": map[string]string{"meter": line.meter, "calculation_digest": line.digest}}
+ return map[string]any{"id": line.item, "invoice": inv.id, "customer": inv.customer, "livemode": false, "amount": amount, "currency": either(f.reportItemCurrency, line.currency), "discountable": r.PostForm.Get("discountable") != "false", "metadata": map[string]string{"meter": line.meter, "calculation_digest": line.digest}}
}, key)
case strings.HasSuffix(path, "/finalize"):
inv := f.invoices[strings.TrimSuffix(strings.TrimPrefix(path, "/v1/invoices/"), "/finalize")]
@@ -226,7 +246,7 @@ func (f *fakeInvoiceStripe) serve(w http.ResponseWriter, r *http.Request) {
out = map[string]any{"id": id, "livemode": false, "address": map[string]string{"country": "IE"}, "shipping": nil, "metadata": map[string]string{"account_sqid": f.customerSqid}, "tax": map[string]any{"automatic_tax": f.customerTax, "location": map[string]string{"country": "IE"}}}
case strings.HasPrefix(path, "/v1/subscriptions/"):
id := strings.TrimPrefix(path, "/v1/subscriptions/")
- out = map[string]any{"id": id, "customer": f.subscriptionCustomer, "status": f.subscriptionStatus, "livemode": false, "created": 1, "current_period_start": 1, "current_period_end": 2, "metadata": map[string]string{"account_sqid": f.sqid}, "items": map[string]any{"data": []any{}, "has_more": false}}
+ out = map[string]any{"id": id, "customer": f.subscriptionCustomer, "status": f.subscriptionStatus, "livemode": false, "created": 1, "current_period_start": 1, "current_period_end": 2, "metadata": map[string]string{"account_sqid": f.sqid}, "currency": f.subscriptionCurrency, "items": map[string]any{"data": []any{}, "has_more": false}}
case path == "/v1/invoices":
q := r.URL.Query()
gte, _ := strconv.ParseInt(q.Get("created[gte]"), 10, 64)
diff --git a/internal/console/billingservice_test.go b/internal/console/billingservice_test.go
index 7046b6164..b60fcc7bc 100644
--- a/internal/console/billingservice_test.go
+++ b/internal/console/billingservice_test.go
@@ -114,6 +114,9 @@ func (f *fakeBillingStripe) session() map[string]any {
if f.sessionCurrency != "" {
currency = f.sessionCurrency
}
+ if currency == "-" { // a session that reports no currency at all
+ currency = ""
+ }
return map[string]any{"currency": currency, "id": "cs_test_paid", "url": "https://checkout.stripe.com/c/paid", "status": f.sessionStatus, "expires_at": f.now.Add(time.Hour).Unix(), "livemode": false, "customer": customer, "subscription": sub, "payment_status": payment, "client_reference_id": f.sqid, "metadata": f.meta, "customer_details": map[string]any{"address": map[string]string{"country": "IE"}}}
}