carlos / platform Public

The console stores one currency per purchase and prices, reconciles and invoices in it

Checkout takes an optional currency (ChooseCurrency: taken, refused, or
the billing country's default), stores it with the price book on the
intent, and sells only a provider price that is in that currency, for
exactly the book's amount, and says so in its own metadata. Sessions are
created in that currency with Adaptive Pricing off. Reconcile reads the
book and currency from the subscription, requires the configured price
for exactly that plan, band, book and currency and a subscription in
that currency, and a subscription never changes currency once recorded.
Assignments carry both, so a month is priced in the currency it was
bought in: a month bought under a book is a version 2 calculation with
currency-neutral names; a version 1 calculation is EUR and marshals as
it always did. The invoice engine sends and verifies the calculation's
own currency, and legacy requests hash as before.

Configuration names a price book and holds a price for every plan, band
and currency; live mode refuses a book whose amounts are not approved.
A valid ?currency= on a GET is kept in a short-lived cookie as a
suggestion for the billing page.

Existing behaviour with no book configured is unchanged, and every
existing billing test passes in that mode. Tests for the new paths
follow.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 6c1a9c2821b713b47f8be168d3d03699bc62a3d0 parent 705acfa
20 files changed, +659 −110
  • cmd/console/billing.go +45 −6
  • cmd/console/billing_test.go +95 −10
  • internal/console/billingcalculation.go +75 −6
  • internal/console/billingcurrency.go +90 −0
  • internal/console/billingflow_demo_test.go +2 −2
  • internal/console/billinghandlers.go +8 −1
  • internal/console/billinginvoice.go +47 −13
  • internal/console/billinginvoicestore.go +2 −2
  • internal/console/billingmembership_test.go +3 −3
  • internal/console/billingpage_preview_test.go +1 −1
  • internal/console/billingpage_test.go +2 −2
  • internal/console/billingpass.go +1 −1
  • internal/console/billingquantity.go +2 −1
  • internal/console/billingquantity_test.go +1 −1
  • internal/console/billingrenewal_test.go +1 −1
  • internal/console/billingservice.go +126 −22
  • internal/console/billingservice_test.go +75 −23
  • internal/console/billingstore.go +78 −11
  • internal/console/entitlementpass_test.go +4 −4
  • internal/console/server.go +1 −0

CI passed — run details

diff --git a/cmd/console/billing.go b/cmd/console/billing.go
index 4beaab333..76cb3cf76 100644
--- a/cmd/console/billing.go
+++ b/cmd/console/billing.go
@@ -87,18 +87,57 @@ func billingConfig(getenv func(string) string) (*console.BillingConfig, error) {
return nil, errBillingConfiguration
}
cfg.WebhookSecrets = webhookSecrets
+ // The price book purchases are priced from
+ // (2026-10-01-multi-currency-price-book-design.md). It has to be
+ // named: there is no default book, and a console with none would
+ // record new purchases with no currency, which is what only a row
+ // from before price books may look like. A book whose amounts have
+ // not been approved can be configured in test mode and never in live
+ // mode.
+ cfg.PriceBook = getenv("CONSOLE_BILLING_PRICE_BOOK")
+ currencies, err := plans.BookCurrencies(cfg.PriceBook)
+ if cfg.PriceBook == plans.LegacyPriceBook || err != nil {
+ return nil, errBillingConfiguration
+ }
+ if approved, err := plans.BookIsApproved(cfg.PriceBook); err != nil || (cfg.LiveMode && !approved) {
+ return nil, errBillingConfiguration
+ }
+ // One provider price for every plan, band and currency the book sells
+ // in, keyed "<book>/<plan>/<band>/<currency>". The twelve keys from
+ // before books, "<terms>/<plan>/<band>", may be given as well, all of
+ // them or none: they are only ever used to recognise a subscription
+ // bought before books. Nothing else may be in the map, and no price
+ // may appear under two keys.
+ paid := []string{plans.PlanPersonal, plans.PlanCommunity, plans.PlanBusiness}
+ bands := []string{plans.BandA, plans.BandB, plans.BandC, plans.BandD}
raw := getenv("CONSOLE_STRIPE_PRICES")
- if len(raw) > 16*1024 || json.Unmarshal([]byte(raw), &cfg.Prices) != nil || len(cfg.Prices) != 12 {
+ if len(raw) > 32*1024 || json.Unmarshal([]byte(raw), &cfg.Prices) != nil {
+ return nil, errBillingConfiguration
+ }
+ want := len(paid) * len(bands) * len(currencies)
+ legacy := len(paid) * len(bands)
+ if len(cfg.Prices) != want && len(cfg.Prices) != want+legacy {
return nil, errBillingConfiguration
}
seen := map[string]bool{}
- for _, plan := range []string{plans.PlanPersonal, plans.PlanCommunity, plans.PlanBusiness} {
- for _, band := range []string{plans.BandA, plans.BandB, plans.BandC, plans.BandD} {
- id := cfg.Prices[plans.CurrentTermsVersion+"/"+plan+"/"+band]
- if !strings.HasPrefix(id, "price_") || len(id) <= 6 || seen[id] {
+ check := func(key string) bool {
+ id := cfg.Prices[key]
+ if !strings.HasPrefix(id, "price_") || len(id) <= 6 || seen[id] {
+ return false
+ }
+ seen[id] = true
+ return true
+ }
+ for _, plan := range paid {
+ for _, band := range bands {
+ for _, currency := range currencies {
+ if !check(cfg.PriceBook + "/" + plan + "/" + band + "/" + string(currency)) {
+ return nil, errBillingConfiguration
+ }
+ }
+ if len(cfg.Prices) == want+legacy && !check(plans.CurrentTermsVersion+"/"+plan+"/"+band) {
return nil, errBillingConfiguration
}
- seen[id] = true
}
}
return cfg, nil
diff --git a/cmd/console/billing_test.go b/cmd/console/billing_test.go
index 56c937133..768de5228 100644
--- a/cmd/console/billing_test.go
+++ b/cmd/console/billing_test.go
@@ -11,12 +11,7 @@ import (
func billingEnvFixture(t *testing.T) map[string]string {
t.Helper()
- prices := map[string]string{}
- for _, plan := range []string{"personal", "community", "business"} {
- for _, band := range []string{"A", "B", "C", "D"} {
- prices[plans.CurrentTermsVersion+"/"+plan+"/"+band] = "price_" + plan + band
- }
- }
+ prices := billingPricesFixture(false)
raw, err := json.Marshal(prices)
if err != nil {
t.Fatal(err)
@@ -38,9 +33,27 @@ func billingEnvFixture(t *testing.T) map[string]string {
"CARLOS_STRIPE_WEBHOOK_SECRET": "whsec_current",
"CARLOS_STRIPE_PREVIOUS_WEBHOOK_SECRETS": "whsec_previous",
"CONSOLE_STRIPE_PRICES": string(raw),
+ "CONSOLE_BILLING_PRICE_BOOK": plans.CurrentPriceBook,
}
}
+// billingPricesFixture is one price for every plan, band and currency of
+// the current book, and with legacy the twelve keys from before books too.
+func billingPricesFixture(legacy bool) map[string]string {
+ prices := map[string]string{}
+ for _, plan := range []string{"personal", "community", "business"} {
+ for _, band := range []string{"A", "B", "C", "D"} {
+ for _, currency := range []string{"usd", "eur", "gbp"} {
+ prices[plans.CurrentPriceBook+"/"+plan+"/"+band+"/"+currency] = "price_" + plan + band + currency
+ }
+ if legacy {
+ prices[plans.CurrentTermsVersion+"/"+plan+"/"+band] = "price_" + plan + band
+ }
+ }
+ }
+ return prices
+}
+
func TestBillingConfigDefaultsOffWithoutReadingCredentials(t *testing.T) {
for _, mode := range []string{"", "off"} {
cfg, err := billingConfig(func(key string) string {
@@ -62,17 +75,89 @@ func TestBillingConfigExplicitModeAndCompleteCatalog(t *testing.T) {
if err != nil || cfg == nil {
t.Fatalf("valid config: %v", err)
}
- if cfg.LiveMode || len(cfg.Prices) != 12 || len(cfg.WebhookSecrets) != 2 || cfg.CountryBands["IE"] != "A" {
+ if cfg.LiveMode || len(cfg.Prices) != 36 || cfg.PriceBook != plans.CurrentPriceBook || len(cfg.WebhookSecrets) != 2 || cfg.CountryBands["IE"] != "A" {
t.Fatal("incomplete config")
}
env["CONSOLE_BILLING_MODE"] = "live"
if _, err := billingConfig(get); !errors.Is(err, errBillingConfiguration) {
t.Fatal("live accepted test key")
}
+ // The current book's amounts have not been approved. Live mode refuses
+ // it, with a live key and everything else in order. When a book is
+ // approved this changes with it, on purpose.
env["CARLOS_STRIPE_SECRET_KEY"] = "rk_live_secret_must_not_escape"
- cfg, err = billingConfig(get)
- if err != nil || !cfg.LiveMode {
- t.Fatalf("live config: %v", err)
+ if approved, err := plans.BookIsApproved(plans.CurrentPriceBook); err != nil || approved {
+ t.Fatalf("fixture: the current book is approved (%v); this test needs revisiting", err)
+ }
+ if _, err := billingConfig(get); !errors.Is(err, errBillingConfiguration) {
+ t.Fatal("live mode accepted a price book whose amounts are not approved")
+ }
+}
+
+// The price book has to be named and known, and the price map has to hold
+// exactly one price for every plan, band and currency of it, with the
+// twelve keys from before books all present or all absent.
+func TestBillingConfigPriceBookAndCatalog(t *testing.T) {
+ config := func(edit func(env map[string]string, prices map[string]string)) error {
+ env := billingEnvFixture(t)
+ prices := billingPricesFixture(false)
+ edit(env, prices)
+ if _, own := env["prices-set"]; !own {
+ raw, _ := json.Marshal(prices)
+ env["CONSOLE_STRIPE_PRICES"] = string(raw)
+ }
+ delete(env, "prices-set")
+ _, err := billingConfig(func(key string) string { return env[key] })
+ return err
+ }
+ if err := config(func(map[string]string, map[string]string) {}); err != nil {
+ t.Fatalf("the fixture: %v", err)
+ }
+ if err := config(func(env map[string]string, prices map[string]string) {
+ for k, v := range billingPricesFixture(true) {
+ prices[k] = v
+ }
+ }); err != nil {
+ t.Fatalf("with the twelve legacy keys as well: %v", err)
+ }
+ for name, edit := range map[string]func(env map[string]string, prices map[string]string){
+ "no book": func(env map[string]string, _ map[string]string) { delete(env, "CONSOLE_BILLING_PRICE_BOOK") },
+ "unknown book": func(env map[string]string, _ map[string]string) { env["CONSOLE_BILLING_PRICE_BOOK"] = "2031-01-01-v9" },
+ "a price missing for one currency": func(_ map[string]string, prices map[string]string) {
+ delete(prices, plans.CurrentPriceBook+"/community/C/gbp")
+ },
+ "only the twelve legacy keys": func(env map[string]string, prices map[string]string) {
+ 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"
+ },
+ "a key that is neither": func(_ map[string]string, prices map[string]string) {
+ prices[plans.CurrentPriceBook+"/personal/A/chf"] = "price_chf"
+ },
+ "one price under two currencies": func(_ map[string]string, prices map[string]string) {
+ prices[plans.CurrentPriceBook+"/personal/A/usd"] = prices[plans.CurrentPriceBook+"/personal/A/eur"]
+ },
+ "a legacy key sharing a book price": func(_ map[string]string, prices map[string]string) {
+ for k, v := range billingPricesFixture(true) {
+ prices[k] = v
+ }
+ prices[plans.CurrentTermsVersion+"/personal/A"] = prices[plans.CurrentPriceBook+"/personal/A/eur"]
+ },
+ "a price that is not a price id": func(_ map[string]string, prices map[string]string) {
+ prices[plans.CurrentPriceBook+"/business/D/usd"] = "prod_business"
+ },
+ } {
+ if err := config(edit); !errors.Is(err, errBillingConfiguration) {
+ t.Errorf("%s: %v, want a configuration error", name, err)
+ }
}
}
diff --git a/internal/console/billingcalculation.go b/internal/console/billingcalculation.go
index aaf1a5602..a8efdde8f 100644
--- a/internal/console/billingcalculation.go
+++ b/internal/console/billingcalculation.go
@@ -35,17 +35,71 @@ type billingCalculation struct {
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"`
+ // Overage and Invoice are a version 1 calculation's money: EUR, under
+ // the names it was always written with. A version 1 calculation
+ // marshals exactly as it did before version 2 existed; its digest is
+ // of those bytes. They are absent from a version 2 calculation.
+ Overage *plans.OverageResult `json:"overage,omitempty"`
+ Invoice *plans.InvoiceResult `json:"invoice,omitempty"`
+ Lines []plans.OverageLine `json:"lines"`
+ TaxCode string `json:"tax_code"`
// CountedDays is set only for a month a paid plan covered partly
// (Allowance.Partial): the UTC days whose usage was billed, each one
// wholly inside a paid interval. Usage on every other day of the month
// was not billed.
CountedDays []string `json:"counted_days,omitempty"`
+ // PriceBook, Charges and Total are a version 2 calculation's money: a
+ // month priced from a price book, in the currency the subscription
+ // was bought in (Currency, which is also Charges' and Total's own).
+ // Nothing in a version 2 calculation is in a currency other than that
+ // one, and nothing is named for a currency it might not be in.
+ PriceBook string `json:"price_book,omitempty"`
+ Charges *plans.Charges `json:"charges,omitempty"`
+ Total *plans.Invoice `json:"total,omitempty"`
}
+// wellFormed reports whether the calculation is whole for its version and
+// agrees with itself about its money: a version 1 calculation is EUR from
+// the terms' own figures and nothing else; a version 2 calculation names a
+// book and a currency the book sells in, and its allowance, charges and
+// total are all in that currency. A calculation read back from storage
+// that is not well formed is not acted on.
+func (c *billingCalculation) wellFormed() bool {
+ terms := c.Allowance.PriceTerms()
+ switch c.V {
+ case 1:
+ return c.Currency == string(plans.EUR) && c.Overage != nil && c.Invoice != nil && c.PriceBook == "" && c.Charges == nil && c.Total == nil &&
+ terms.Legacy() && terms.Currency == ""
+ case 2:
+ return c.Overage == nil && c.Invoice == nil && c.Charges != nil && c.Total != nil && !terms.Legacy() && terms.Validate() == nil &&
+ terms.PriceBook == c.PriceBook && string(terms.Currency) == c.Currency && c.Charges.Currency == terms.Currency && c.Total.Currency == terms.Currency
+ }
+ return false
+}
+
+// waived and totalMinor read the invoice total of either version. They are
+// meaningful only for a well-formed calculation.
+func (c *billingCalculation) waived() bool {
+ if c.V == 1 {
+ return c.Invoice != nil && c.Invoice.Waived
+ }
+ return c.Total != nil && c.Total.Waived
+}
+
+func (c *billingCalculation) totalMinor() int64 {
+ switch {
+ case c.V == 1 && c.Invoice != nil:
+ return c.Invoice.TotalCents
+ case c.V == 2 && c.Total != nil:
+ return c.Total.TotalMinor
+ }
+ return 0
+}
+
+// legacy reports a version 1 calculation: one whose provider requests were,
+// or would have been, made before a currency was a parameter of them.
+func (c *billingCalculation) legacy() bool { return c.V == 1 }
+
func prepareBillingCalculation(ctx context.Context, store release.Store, statement usage.Statement, history []plans.Assignment, now time.Time) (*billingCalculation, error) {
if store == nil {
return nil, usage.ErrEvidenceChanged
@@ -112,6 +166,22 @@ func priceBillingCalculation(statement usage.Statement, history []plans.Assignme
return nil, plans.ErrOverflow
}
used := plans.Resources{AwakeSeconds: amounts[0], StorageByteMonths: amounts[1], TransferBytes: amounts[2] + amounts[3], Writes: amounts[4]}
+ digest := sha256.Sum256(raw)
+ // A month bought under a price book is priced from that book in the
+ // currency the subscription was bought in, and written as version 2.
+ // The currency is the assignments' own: it is not chosen here, and a
+ // month whose paid spans disagree on it was refused above.
+ if terms := allowance.PriceTerms(); !terms.Legacy() {
+ charges, err := plans.OverageFor(terms, allowance.Assignments[0].Plan, used, allowance.Resources)
+ if err != nil {
+ return nil, err
+ }
+ total, lines, err := plans.OverageInvoiceFor(terms, *charges, allowance.House)
+ if err != nil {
+ return nil, err
+ }
+ return &billingCalculation{V: 2, Arithmetic: arithmetic, Currency: string(terms.Currency), PriceBook: terms.PriceBook, Statement: statement, StatementSHA256: hex.EncodeToString(digest[:]), Allowance: *allowance, Usage: used, Charges: charges, Total: total, Lines: lines, CountedDays: counted}, nil
+ }
overage, err := plans.CalculateOverage(allowance.TermsVersion, allowance.Assignments[0].Plan, used, allowance.Resources)
if err != nil {
return nil, err
@@ -120,8 +190,7 @@ func priceBillingCalculation(statement usage.Statement, history []plans.Assignme
if err != nil {
return nil, err
}
- digest := sha256.Sum256(raw)
- return &billingCalculation{V: 1, Arithmetic: arithmetic, Currency: "eur", Statement: statement, StatementSHA256: hex.EncodeToString(digest[:]), Allowance: *allowance, Usage: used, Overage: *overage, Invoice: *invoice, Lines: lines, CountedDays: counted}, nil
+ return &billingCalculation{V: 1, Arithmetic: arithmetic, Currency: "eur", Statement: statement, StatementSHA256: hex.EncodeToString(digest[:]), Allowance: *allowance, Usage: used, Overage: overage, Invoice: invoice, Lines: lines, CountedDays: counted}, nil
}
// errBillingDays means a partly covered month cannot be billed from this
diff --git a/internal/console/billingcurrency.go b/internal/console/billingcurrency.go
new file mode 100644
index 000000000..9879a17fa
--- /dev/null
+++ b/internal/console/billingcurrency.go
@@ -0,0 +1,90 @@
+package console
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/carlosframework/platform/internal/plans"
+)
+
+// A buyer's choice of currency, carried from the pricing page to the
+// checkout form (2026-10-01-multi-currency-price-book-design.md).
+//
+// The pricing page links to the console with ?currency=<code>. Between
+// that link and the billing page there is a sign-in at Keymail and one or
+// more redirects, none of which carry a query string of ours. So a valid
+// code on any GET is kept in a short-lived cookie, and the billing page
+// reads it to preselect the currency.
+//
+// The cookie is a suggestion and nothing more. The purchase's currency is
+// the "currency" field of the checkout form, validated when it is posted
+// (startBillingCheckout): a cookie cannot choose a currency, and a value
+// that is not a currency sold is dropped here, never stored and never
+// turned into a default. There is no lookup of where the visitor is.
+
+const (
+ currencyCookie = "carloku_currency"
+ currencyCookieTTL = time.Hour
+)
+
+// rememberCurrency keeps a valid ?currency= from a GET in the cookie. It
+// is called for every request before routing; a request with no such
+// parameter, or with one that is not a currency sold, sets nothing.
+func (s *Server) rememberCurrency(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.RawQuery == "" {
+ return
+ }
+ values, ok := r.URL.Query()["currency"]
+ if !ok || len(values) != 1 {
+ return
+ }
+ if c, ok := s.soldCurrency(values[0]); ok {
+ s.setCookie(w, currencyCookie, c, int(currencyCookieTTL.Seconds()))
+ }
+}
+
+// soldCurrency reports whether code is a currency the console sells in:
+// one of the configured price book's, or EUR alone when purchases are
+// priced the way they were before books.
+func (s *Server) soldCurrency(code string) (string, bool) {
+ c, err := plans.ParseCurrency(code)
+ if err != nil {
+ return "", false
+ }
+ for _, sold := range s.billingCurrencies() {
+ if sold == string(c) {
+ return sold, true
+ }
+ }
+ return "", false
+}
+
+// billingCurrencies lists the currencies a buyer may choose among, in the
+// price book's order. It is empty when billing is switched off.
+func (s *Server) billingCurrencies() []string {
+ if s.billing == nil {
+ return nil
+ }
+ list, err := plans.BookCurrencies(s.billing.cfg.PriceBook)
+ if err != nil {
+ return nil
+ }
+ out := make([]string, 0, len(list))
+ for _, c := range list {
+ out = append(out, string(c))
+ }
+ return out
+}
+
+// suggestedCurrency is the currency to preselect on the billing page: the
+// one the visitor arrived with, if it is still one the console sells in,
+// or none. With none, the billing country the owner picks decides the
+// default.
+func (s *Server) suggestedCurrency(r *http.Request) string {
+ c, err := r.Cookie(currencyCookie)
+ if err != nil {
+ return ""
+ }
+ code, _ := s.soldCurrency(c.Value)
+ return code
+}
diff --git a/internal/console/billingflow_demo_test.go b/internal/console/billingflow_demo_test.go
index f24cc9fc2..5d2973467 100644
--- a/internal/console/billingflow_demo_test.go
+++ b/internal/console/billingflow_demo_test.go
@@ -77,7 +77,7 @@ func TestBillingFlowDemo(t *testing.T) {
prices := map[string]string{}
for _, plan := range billingPaidPlans {
for _, band := range []string{plans.BandA, plans.BandB, plans.BandC, plans.BandD} {
- prices[billingPriceKey(plans.CurrentTermsVersion, plan, band)] = "price_" + plan + "_" + band
+ prices[billingPriceKey(plans.LegacyPriceTerms(plans.CurrentTermsVersion), plan, band)] = "price_" + plan + "_" + band
}
}
srv.billing = &billingRuntime{locks: newStoreWalkLocks(), client: client, cfg: BillingConfig{
@@ -346,7 +346,7 @@ func (d *demoStripe) sessionJSON(s *demoSession) map[string]any {
if s.Status == "complete" {
sub = "sub_demo"
}
- return map[string]any{"id": s.ID, "url": "https://checkout.stripe.com/c/" + s.ID, "status": s.Status, "expires_at": s.Expires, "livemode": false, "customer": "cus_demo", "subscription": sub, "payment_status": s.Payment, "client_reference_id": s.Sqid, "metadata": s.Meta, "customer_details": map[string]any{"address": map[string]string{"country": d.customerCountry}}}
+ return map[string]any{"id": s.ID, "url": "https://checkout.stripe.com/c/" + s.ID, "status": s.Status, "expires_at": s.Expires, "livemode": false, "customer": "cus_demo", "subscription": sub, "payment_status": s.Payment, "client_reference_id": s.Sqid, "metadata": s.Meta, "customer_details": map[string]any{"address": map[string]string{"country": d.customerCountry}}, "currency": "eur"}
}
// servePages is the browser-facing half: the simulated checkout and portal.
diff --git a/internal/console/billinghandlers.go b/internal/console/billinghandlers.go
index dd082f596..7dc7e4e0a 100644
--- a/internal/console/billinghandlers.go
+++ b/internal/console/billinghandlers.go
@@ -62,7 +62,14 @@ func (s *Server) handleBillingCheckout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, back+"?error=unavailable", http.StatusSeeOther)
return
}
- dest, err := s.startBillingCheckout(r.Context(), acct, r.PostForm.Get("plan"), r.PostForm.Get("country"), r.PostForm.Get("postal"), time.Now())
+ // The currency is the form's own field. Absent or empty, the billing
+ // country decides. Anything else that is not a currency sold is a
+ // malformed request: nothing is created and no default stands in.
+ dest, err := s.startBillingCheckout(r.Context(), acct, r.PostForm.Get("plan"), r.PostForm.Get("country"), r.PostForm.Get("postal"), r.PostForm.Get("currency"), time.Now())
+ if errors.Is(err, errBillingCurrency) {
+ http.Error(w, "currency_invalid", http.StatusBadRequest)
+ return
+ }
if err != nil {
log.Printf("console: checkout: %s", billingDiagnostic(err))
http.Redirect(w, r, back+"?error="+billingPublicError(err), http.StatusSeeOther)
diff --git a/internal/console/billinginvoice.go b/internal/console/billinginvoice.go
index bc94209de..4cfa80b81 100644
--- a/internal/console/billinginvoice.go
+++ b/internal/console/billinginvoice.go
@@ -324,7 +324,10 @@ func (s *Server) invoiceBillingMonth(ctx context.Context, acct *Account, month s
if err := json.Unmarshal(rec.Calculation, &r.calc); err != nil {
return err
}
- if r.calc.Statement.Account != acct.Sqid || r.calc.Statement.Month != month || r.calc.Currency != "eur" || !strings.HasPrefix(r.calc.TaxCode, "txcd_") || len(r.calc.TaxCode) <= 5 {
+ // The stored calculation must be whole for its version and name one
+ // currency throughout. One that is not is never invoiced, and is never
+ // priced again in some other currency: it is a record to look at.
+ if r.calc.Statement.Account != acct.Sqid || r.calc.Statement.Month != month || !r.calc.wellFormed() || !strings.HasPrefix(r.calc.TaxCode, "txcd_") || len(r.calc.TaxCode) <= 5 {
return r.review("billing_invoice_record_mismatch")
}
if err := r.reload(); err != nil {
@@ -341,7 +344,7 @@ func (s *Server) invoiceBillingMonth(ctx context.Context, acct *Account, month s
return nil
}
before := r.rec
- if r.rec.State != "frozen" || !r.calc.Invoice.Waived {
+ if r.rec.State != "frozen" || !r.calc.waived() {
if !leased {
if b.invoice.hooks != nil && b.invoice.hooks.beforeLease != nil {
if err := b.invoice.hooks.beforeLease(); err != nil {
@@ -502,6 +505,11 @@ func invoiceIdentityFromHistory(calc *billingCalculation, rows []billingAssignme
if !ok || !row.EffectiveAt.Equal(a.EffectiveAt) || row.Plan != a.Plan || row.Quantity != a.Quantity || row.Customer == "" || row.Subscription == "" {
return billingInvoiceIdentity{}, errBillingIdentity
}
+ // The currency and book the calculation was priced in are the
+ // recorded assignment's own, now as when it was frozen.
+ if row.PriceBook != a.PriceBook || row.Currency != a.Currency {
+ return billingInvoiceIdentity{}, errBillingIdentity
+ }
if i == 0 {
id = billingInvoiceIdentity{Customer: row.Customer, Subscription: row.Subscription, LiveMode: row.LiveMode}
continue
@@ -548,6 +556,12 @@ func (r *billingInvoiceRun) preflight(ctx context.Context, id *billingInvoiceIde
if sub.ID != id.Subscription || sub.LiveMode != r.b.cfg.LiveMode || sub.Metadata["account_sqid"] != r.acct.Sqid || sub.CustomerID != id.Customer {
return errBillingIdentity
}
+ // The usage is invoiced in the currency the subscription was bought
+ // in. A subscription the provider now reports in another currency is
+ // not the one this month was metered under.
+ if !billingCurrencyAgrees(r.calc.Allowance.PriceTerms(), sub.Currency) {
+ return errBillingIdentity
+ }
if !persisted {
id.LinkSubscription = sub.Status != "canceled" && sub.Status != "incomplete_expired"
}
@@ -574,13 +588,13 @@ func (r *billingInvoiceRun) boundIdentity() (billingInvoiceIdentity, error) {
// stepFrozen is steps 2 and 3: waive, or bind the provider identity.
func (r *billingInvoiceRun) stepFrozen(ctx context.Context) error {
- if r.calc.Invoice.Waived {
+ if r.calc.waived() {
if len(r.calc.Lines) != 0 {
return r.review("billing_invoice_record_mismatch")
}
return r.s.store.advanceBillingMonth(r.rec, "waived", "")
}
- if len(r.calc.Lines) == 0 || r.calc.Invoice.TotalCents <= 0 {
+ if len(r.calc.Lines) == 0 || r.calc.totalMinor() <= 0 {
return r.review("billing_invoice_record_mismatch")
}
rows, err := r.s.store.billingInvoiceHistory(r.acct.ID)
@@ -826,7 +840,7 @@ func (r *billingInvoiceRun) acknowledge(p providerOp, op billingOperation, objec
func (r *billingInvoiceRun) verifyInvoice(inv *billingstripe.Invoice, statuses ...string) error {
if inv == nil || r.rec.InvoiceID != "" && inv.ID != r.rec.InvoiceID || inv.Customer != r.rec.Customer || inv.LiveMode != r.b.cfg.LiveMode ||
inv.Metadata["account_sqid"] != r.acct.Sqid || inv.Metadata["usage_month"] != r.month || inv.Metadata["calculation_digest"] != r.rec.Digest || inv.Metadata["subscription"] != r.rec.Subscription ||
- inv.Currency != "eur" || inv.AutoAdvance || inv.HasDiscounts() || !inv.AutomaticTax.Enabled || inv.CollectionMethod != "" && inv.CollectionMethod != "charge_automatically" {
+ inv.Currency != r.calc.Currency || inv.AutoAdvance || inv.HasDiscounts() || !inv.AutomaticTax.Enabled || inv.CollectionMethod != "" && inv.CollectionMethod != "charge_automatically" {
return errBillingInvoiceMismatch
}
wantLink := ""
@@ -844,8 +858,28 @@ func (r *billingInvoiceRun) verifyInvoice(inv *billingstripe.Invoice, statuses .
return errBillingInvoiceMismatch
}
+// requestHash is the hash an operation's request is journaled under. A
+// request's currency is one of its parameters and so part of its hash. The
+// one exception is a version 1 calculation's requests: those were hashed
+// before a currency was a parameter, always EUR, and they hash as they did,
+// so an operation journaled by an earlier build is still recognised as the
+// same request. The request that is sent always names its currency.
+func (r *billingInvoiceRun) requestHash(kind string, params any) string {
+ if r.calc.legacy() {
+ switch p := params.(type) {
+ case billingstripe.DraftInvoiceParams:
+ p.Currency = ""
+ params = p
+ case billingstripe.InvoiceItemParams:
+ p.Currency = ""
+ params = p
+ }
+ }
+ return billingRequestHash(kind, params)
+}
+
func (r *billingInvoiceRun) draftParams() billingstripe.DraftInvoiceParams {
- p := billingstripe.DraftInvoiceParams{Customer: r.rec.Customer, SubscriptionRef: r.rec.Subscription, Account: r.acct.Sqid, Month: r.month, CalculationDigest: r.rec.Digest}
+ p := billingstripe.DraftInvoiceParams{Customer: r.rec.Customer, SubscriptionRef: r.rec.Subscription, Account: r.acct.Sqid, Month: r.month, CalculationDigest: r.rec.Digest, Currency: r.calc.Currency}
if r.rec.LinkSubscription {
p.Subscription = r.rec.Subscription
}
@@ -863,7 +897,7 @@ func (r *billingInvoiceRun) stepCreating(ctx context.Context) error {
}
params := r.draftParams()
return r.perform(ctx, providerOp{
- kind: "draft", hash: billingRequestHash("draft", params),
+ kind: "draft", hash: r.requestHash("draft", params),
post: func(ctx context.Context, key string) (string, string, error) {
inv, err := r.b.client.CreateDraftInvoice(ctx, params, key)
if err != nil {
@@ -904,7 +938,7 @@ func (r *billingInvoiceRun) lineParams(line plans.OverageLine) (billingstripe.In
}
return billingstripe.InvoiceItemParams{Customer: r.rec.Customer, Invoice: r.rec.InvoiceID, Meter: line.Meter, TaxCode: r.calc.TaxCode,
Description: billingInvoiceLineDescription(line.Meter, r.month), CalculationDigest: r.rec.Digest,
- AmountCents: line.AmountCents, PeriodStart: r.start.Unix(), PeriodEnd: r.end.Unix()}, nil
+ AmountCents: line.AmountCents, PeriodStart: r.start.Unix(), PeriodEnd: r.end.Unix(), Currency: r.calc.Currency}, nil
}
// billingInvoiceLineDescription is what the customer reads on the invoice
@@ -938,13 +972,13 @@ func (r *billingInvoiceRun) stepDraft(ctx context.Context) error {
}
kind := "line-" + line.Meter
if err := r.perform(ctx, providerOp{
- kind: kind, hash: billingRequestHash(kind, params),
+ kind: kind, hash: r.requestHash(kind, params),
post: func(ctx context.Context, key string) (string, string, error) {
item, err := r.b.client.CreateInvoiceItem(ctx, params, key)
if err != nil {
return "", "", err
}
- if item.Invoice != r.rec.InvoiceID || item.Customer != r.rec.Customer || item.LiveMode != r.b.cfg.LiveMode || item.Amount != line.AmountCents || item.Currency != "eur" || item.Discountable || item.Metadata["meter"] != line.Meter || item.Metadata["calculation_digest"] != r.rec.Digest {
+ if item.Invoice != r.rec.InvoiceID || item.Customer != r.rec.Customer || item.LiveMode != r.b.cfg.LiveMode || item.Amount != line.AmountCents || item.Currency != r.calc.Currency || item.Discountable || item.Metadata["meter"] != line.Meter || item.Metadata["calculation_digest"] != r.rec.Digest {
return item.ID, "attached", errBillingInvoiceMismatch
}
return item.ID, "attached", nil
@@ -1056,13 +1090,13 @@ func (r *billingInvoiceRun) validateDraftLines(ctx context.Context) error {
var sum int64
for _, l := range lines {
want, ok := expected[l.InvoiceItem]
- if !ok || seen[l.InvoiceItem] || l.Amount != want.AmountCents || l.Currency != "eur" || l.Discountable || l.Metadata["meter"] != want.Meter || l.Metadata["calculation_digest"] != r.rec.Digest {
+ if !ok || seen[l.InvoiceItem] || l.Amount != want.AmountCents || l.Currency != r.calc.Currency || l.Discountable || l.Metadata["meter"] != want.Meter || l.Metadata["calculation_digest"] != r.rec.Digest {
return r.review("billing_invoice_lines_mismatch")
}
seen[l.InvoiceItem] = true
sum += l.Amount
}
- if len(seen) != len(expected) || sum != r.calc.Invoice.TotalCents || inv.Subtotal != sum {
+ if len(seen) != len(expected) || sum != r.calc.totalMinor() || inv.Subtotal != sum {
return r.review("billing_invoice_lines_mismatch")
}
return nil
@@ -1072,7 +1106,7 @@ func (r *billingInvoiceRun) verifyFinalized(inv *billingstripe.Invoice) error {
if err := r.verifyInvoice(inv, "open", "paid"); err != nil {
return err
}
- if inv.Subtotal != r.calc.Invoice.TotalCents || inv.Total < inv.Subtotal || inv.AmountDue > inv.Total || inv.AmountDue < 0 {
+ if inv.Subtotal != r.calc.totalMinor() || inv.Total < inv.Subtotal || inv.AmountDue > inv.Total || inv.AmountDue < 0 {
return errBillingInvoiceMismatch
}
if inv.AutomaticTax.Enabled && inv.AutomaticTax.Status != "complete" {
diff --git a/internal/console/billinginvoicestore.go b/internal/console/billinginvoicestore.go
index 8eaf2ce94..c481e305f 100644
--- a/internal/console/billinginvoicestore.go
+++ b/internal/console/billinginvoicestore.go
@@ -527,7 +527,7 @@ type billingAssignmentRow struct {
// because AllowanceForMonth validates revision order against time order over
// everything it is given.
func (s *Store) billingInvoiceHistory(id int64) ([]billingAssignmentRow, error) {
- rows, err := s.db.Query(`SELECT revision,effective_at,terms,plan,band,quantity,house,status,period_end,customer,subscription,live_mode FROM billing_assignments WHERE account_id=? ORDER BY revision`, id)
+ rows, err := s.db.Query(`SELECT revision,effective_at,terms,plan,band,quantity,house,status,period_end,customer,subscription,live_mode,price_book,currency FROM billing_assignments WHERE account_id=? ORDER BY revision`, id)
if err != nil {
return nil, err
}
@@ -536,7 +536,7 @@ func (s *Store) billingInvoiceHistory(id int64) ([]billingAssignmentRow, error)
for rows.Next() {
var a billingAssignmentRow
var effective, paid int64
- if err := rows.Scan(&a.Revision, &effective, &a.TermsVersion, &a.Plan, &a.Band, &a.Quantity, &a.House, &a.Status, &paid, &a.Customer, &a.Subscription, &a.LiveMode); err != nil {
+ if err := rows.Scan(&a.Revision, &effective, &a.TermsVersion, &a.Plan, &a.Band, &a.Quantity, &a.House, &a.Status, &paid, &a.Customer, &a.Subscription, &a.LiveMode, &a.PriceBook, &a.Currency); err != nil {
return nil, err
}
a.EffectiveAt, a.PaidThrough = time.Unix(effective, 0).UTC(), time.Unix(paid, 0).UTC()
diff --git a/internal/console/billingmembership_test.go b/internal/console/billingmembership_test.go
index 365038621..f6f9ec4fb 100644
--- a/internal/console/billingmembership_test.go
+++ b/internal/console/billingmembership_test.go
@@ -1061,7 +1061,7 @@ func TestMembershipWriterInventory(t *testing.T) {
func TestBillingCheckoutPersistsModeAndMemberSnapshot(t *testing.T) {
fx, f, acct := newBillingFixture(t)
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now); err != nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", f.now); err != nil {
t.Fatal(err)
}
members, err := fx.st.Members(acct.ID)
@@ -1079,7 +1079,7 @@ func TestBillingCheckoutPersistsModeAndMemberSnapshot(t *testing.T) {
func TestBillingCheckoutExpiresOpenSessionWithStaleSnapshot(t *testing.T) {
fx, f, acct := newBillingFixture(t)
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now); err != nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", f.now); err != nil {
t.Fatal(err)
}
// An intent from before the snapshot existed.
@@ -1090,7 +1090,7 @@ func TestBillingCheckoutExpiresOpenSessionWithStaleSnapshot(t *testing.T) {
f.mu.Lock()
f.sessionStatus = "open"
f.mu.Unlock()
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now.Add(time.Minute)); err != nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", f.now.Add(time.Minute)); err != nil {
t.Fatal(err)
}
f.mu.Lock()
diff --git a/internal/console/billingpage_preview_test.go b/internal/console/billingpage_preview_test.go
index c23c79e8b..78a7d081a 100644
--- a/internal/console/billingpage_preview_test.go
+++ b/internal/console/billingpage_preview_test.go
@@ -38,7 +38,7 @@ func TestBillingPageRenderPreview(t *testing.T) {
write("owner-free-error-unavailable", owner, fx, "/billing?error=unavailable")
write("owner-free-error-conflict", owner, fx, "/billing?error=conflict")
// Back from Stripe before the webhook: the pending banner.
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now); err != nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", f.now); err != nil {
t.Fatal(err)
}
write("owner-pending", owner, fx, "/billing?pending=1")
diff --git a/internal/console/billingpage_test.go b/internal/console/billingpage_test.go
index 848130a5d..471cb1228 100644
--- a/internal/console/billingpage_test.go
+++ b/internal/console/billingpage_test.go
@@ -205,7 +205,7 @@ func TestBillingPageQuoteAndBanners(t *testing.T) {
// ?pending=1 no longer claims Stripe is still confirming.
func TestBillingPageActiveSubscription(t *testing.T) {
fx, f, acct := newBillingFixture(t)
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now); err != nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", f.now); err != nil {
t.Fatal(err)
}
f.mu.Lock()
@@ -363,7 +363,7 @@ func TestBillingPageDoesNotOfferAPlanCheckoutWouldRefuse(t *testing.T) {
}
}
// The door agrees: Personal for two members is refused.
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", time.Now()); err == nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", time.Now()); err == nil {
t.Fatal("checkout accepted Personal for two members; the page and the door disagree")
}
}
diff --git a/internal/console/billingpass.go b/internal/console/billingpass.go
index ae4e4ff93..8e2d37a3e 100644
--- a/internal/console/billingpass.go
+++ b/internal/console/billingpass.go
@@ -94,7 +94,7 @@ func (s *Store) billingSubscriptionBatch(after int64, limit int) ([]billingState
var out []billingState
for rows.Next() {
var x billingState
- if err := rows.Scan(&x.AccountID, &x.Revision, &x.House, &x.Customer, &x.Subscription, &x.Status, &x.Plan, &x.Terms, &x.Band, &x.Quantity, &x.PeriodStart, &x.PeriodEnd); err != nil {
+ if err := rows.Scan(&x.AccountID, &x.Revision, &x.House, &x.Customer, &x.Subscription, &x.Status, &x.Plan, &x.Terms, &x.Band, &x.Quantity, &x.PeriodStart, &x.PeriodEnd, &x.Currency, &x.PriceBook); err != nil {
return nil, err
}
out = append(out, x)
diff --git a/internal/console/billingquantity.go b/internal/console/billingquantity.go
index 0f71bbfdc..c85037d1f 100644
--- a/internal/console/billingquantity.go
+++ b/internal/console/billingquantity.go
@@ -1013,7 +1013,8 @@ func (s *Server) quantityProviderItem(ctx context.Context, acct *Account, state
}
if sub.ID != state.Subscription || sub.LiveMode != b.cfg.LiveMode || sub.CustomerID != state.Customer ||
sub.Metadata["account_sqid"] != acct.Sqid || len(sub.Items) != 1 || sub.ItemsHasMore ||
- sub.Items[0].PriceID != b.cfg.Prices[billingPriceKey(state.Terms, state.Plan, state.Band)] {
+ sub.Items[0].PriceID != b.cfg.Prices[billingPriceKey(state.priceTerms(), state.Plan, state.Band)] ||
+ !billingCurrencyAgrees(state.priceTerms(), sub.Currency) || !billingCurrencyAgrees(state.priceTerms(), sub.Items[0].PriceCurrency) {
return billingstripe.SubscriptionItem{}, false, errBillingIdentity
}
if sub.Status != "active" || sub.CurrentPeriodStart.Unix() != state.PeriodStart {
diff --git a/internal/console/billingquantity_test.go b/internal/console/billingquantity_test.go
index 0b20e6b45..06bd44ef7 100644
--- a/internal/console/billingquantity_test.go
+++ b/internal/console/billingquantity_test.go
@@ -217,7 +217,7 @@ func newQuantityFixture(t *testing.T, proration string) (*consoleFixture, *quant
fx.srv.billing = &billingRuntime{locks: newStoreWalkLocks(), client: client, cfg: BillingConfig{
MerchantAccount: "acct_carloku", TaxCode: "txcd_test", PortalConfiguration: "bpc_safe", LiveMode: true, Proration: proration, QuantityFence: billingQuantityFenceVerified,
CountryBands: map[string]string{"IE": "A"}, WebhookSecrets: []string{"whsec_test"},
- Prices: map[string]string{billingPriceKey(plans.CurrentTermsVersion, plans.PlanCommunity, "A"): "price_community_A"},
+ Prices: map[string]string{billingPriceKey(plans.LegacyPriceTerms(plans.CurrentTermsVersion), plans.PlanCommunity, "A"): "price_community_A"},
}}
members, err := fx.st.Members(OpsAccountID)
if err != nil {
diff --git a/internal/console/billingrenewal_test.go b/internal/console/billingrenewal_test.go
index 6fc60f127..37a17b079 100644
--- a/internal/console/billingrenewal_test.go
+++ b/internal/console/billingrenewal_test.go
@@ -74,7 +74,7 @@ func newRenewalFixture(t *testing.T) (*consoleFixture, *renewalStripe, *Account)
}
prices := map[string]string{}
for _, plan := range []string{plans.PlanBusiness, plans.PlanCommunity} {
- prices[billingPriceKey(plans.CurrentTermsVersion, plan, plans.BandA)] = "price_" + plan
+ prices[billingPriceKey(plans.LegacyPriceTerms(plans.CurrentTermsVersion), plan, plans.BandA)] = "price_" + plan
}
fx.srv.billing = &billingRuntime{locks: newStoreWalkLocks(), client: client, cfg: BillingConfig{LiveMode: true, MerchantAccount: "acct_carloku", TaxCode: "txcd_test", PortalConfiguration: "bpc_safe", CountryBands: map[string]string{"IE": "A"}, Prices: prices, WebhookSecrets: []string{"whsec_test"}}}
first := billingState{AccountID: acct.ID, Customer: "cus_live", Subscription: "sub_live", Status: "active", Plan: plans.PlanBusiness, Terms: plans.CurrentTermsVersion, Band: plans.BandA, Quantity: 10, PeriodStart: renewalYearStart.Unix(), PeriodEnd: renewalAt.Unix(), LiveMode: true}
diff --git a/internal/console/billingservice.go b/internal/console/billingservice.go
index 98818dff0..50238d005 100644
--- a/internal/console/billingservice.go
+++ b/internal/console/billingservice.go
@@ -18,12 +18,21 @@ import (
type BillingConfig struct {
MerchantAccount string
// TaxCode is the deprecated single-code configuration retained during rollout.
- TaxCode string
- TaxCodes map[string]string
- SecretKey string
- WebhookSecrets []string
- LiveMode bool
- Prices map[string]string
+ TaxCode string
+ TaxCodes map[string]string
+ SecretKey string
+ WebhookSecrets []string
+ LiveMode bool
+ Prices map[string]string
+ // PriceBook is the price book new purchases are priced from
+ // (plans.CurrentPriceBook in a deployment). With one set, a buyer
+ // chooses among its currencies and Prices is keyed by
+ // billingPriceKey with the book and the currency. Empty is the
+ // behaviour from before price books: EUR only, from the terms' own
+ // figures, Prices keyed by terms, plan and band. cmd/console never
+ // leaves it empty; subscriptions recorded that way are still
+ // reconciled, which is what the legacy keys in Prices are for.
+ PriceBook string
CountryBands map[string]string
HouseCoupon string
PortalConfiguration string
@@ -146,9 +155,56 @@ func (cfg BillingConfig) taxCode(plan string) (string, bool) {
return code, ok && strings.HasPrefix(code, "txcd_") && len(code) > 5
}
-func billingPriceKey(terms, plan, band string) string { return terms + "/" + plan + "/" + band }
+// billingPriceKey is the key of the configured provider price for a plan
+// and band under a purchase's price terms. A purchase from a book is keyed
+// by the book and its currency, so no two currencies can share a price; a
+// purchase from before books is keyed as it always was.
+func billingPriceKey(t plans.PriceTerms, plan, band string) string {
+ if t.Legacy() {
+ return t.TermsVersion + "/" + plan + "/" + band
+ }
+ return t.PriceBook + "/" + plan + "/" + band + "/" + string(t.Currency)
+}
+
+// billingCurrencyAgrees reports whether a currency the provider reports for
+// an object agrees with the purchase's price terms. For a purchase from a
+// book the provider must say the currency, and it must be the purchase's.
+// For one from before books it must be EUR when it is said at all.
+func billingCurrencyAgrees(t plans.PriceTerms, reported string) bool {
+ if t.Legacy() {
+ return reported == "" || reported == string(plans.EUR)
+ }
+ return reported == string(t.Currency)
+}
+
+// errBillingCurrency: the buyer named a currency that is not one of the
+// currencies sold. Nothing is created and no default is substituted.
+var errBillingCurrency = errors.New("billing_currency_invalid")
-func (s *Server) startBillingCheckout(ctx context.Context, acct *Account, plan, country, postal string, now time.Time) (string, error) {
+// priceTermsFromMetadata reads the commercial identity a provider object
+// (a session, a subscription, a price) says it was made under. An object
+// with no price book is one from before books: EUR, and it may say "eur"
+// or nothing, but nothing else.
+func priceTermsFromMetadata(md map[string]string) (plans.PriceTerms, error) {
+ t := plans.PriceTerms{TermsVersion: md["terms_version"], PriceBook: md["price_book"], Currency: plans.Currency(md["currency"])}
+ if err := t.Validate(); err != nil {
+ return plans.PriceTerms{}, err
+ }
+ if t.Legacy() {
+ t.Currency = ""
+ }
+ return t, nil
+}
+
+// startBillingCheckout opens (or resumes) a purchase of plan for the
+// account, billed to country, in currency.
+//
+// currency is what the buyer chose, or empty for the billing country's
+// default. One that is not a currency sold is refused with
+// errBillingCurrency before anything is read or created: it is never
+// replaced by a default. The band comes from the country alone, so the
+// choice of currency changes the money and nothing else.
+func (s *Server) startBillingCheckout(ctx context.Context, acct *Account, plan, country, postal, currency string, now time.Time) (string, error) {
b := s.billing
if b == nil {
return "", errBillingUnavailable
@@ -180,11 +236,25 @@ func (s *Server) startBillingCheckout(ctx context.Context, acct *Account, plan,
return "", err
}
qty := max(int64(len(members)), def.MinQuantity)
- quote, err := plans.Quote(plans.CurrentTermsVersion, plan, band, qty)
+ chosen, err := plans.ChooseCurrency(b.cfg.PriceBook, currency, country)
if err != nil {
+ if errors.Is(err, plans.ErrUnknownCurrency) {
+ return "", errBillingCurrency
+ }
+ return "", err
+ }
+ terms := plans.PriceTerms{TermsVersion: plans.CurrentTermsVersion, PriceBook: b.cfg.PriceBook, Currency: chosen}
+ if terms.Legacy() {
+ terms.Currency = ""
+ }
+ quote, err := plans.PriceFor(terms, plan, band, qty)
+ if err != nil {
+ if errors.Is(err, plans.ErrPriceNotWhole) {
+ return "", errBillingUnavailable
+ }
return "", err
}
- priceID := b.cfg.Prices[billingPriceKey(quote.TermsVersion, plan, band)]
+ priceID := b.cfg.Prices[billingPriceKey(terms, plan, band)]
if priceID == "" {
return "", errBillingUnavailable
}
@@ -199,7 +269,17 @@ func (s *Server) startBillingCheckout(ctx context.Context, acct *Account, plan,
if err != nil {
return "", err
}
- if len(price.CurrencyOptions) != 0 || quote.AnnualPriceMicroEUR%qty != 0 || (quote.AnnualPriceMicroEUR/qty)%10000 != 0 || price.BillingScheme != "per_unit" || (len(price.TransformQuantity) > 0 && string(price.TransformQuantity) != "null") || price.Metadata["terms_version"] != quote.TermsVersion || price.Metadata["plan"] != plan || price.Metadata["band"] != band || price.ID != priceID || !price.Active || price.LiveMode != b.cfg.LiveMode || price.Currency != "eur" || price.TaxBehavior != "exclusive" || price.Recurring.Interval != "year" || price.Recurring.IntervalCount != 1 || price.Recurring.UsageType != "licensed" || price.UnitAmount != quote.AnnualPriceMicroEUR/qty/10000 {
+ // The provider's price must be this purchase's price: in the chosen
+ // currency and no other (no currency options), for exactly the book's
+ // amount for one member, and saying in its own metadata which terms,
+ // plan, band, book and currency it is for. A price configured under
+ // the wrong key, or one whose amount or currency is not the book's, is
+ // not sold.
+ priceTerms, err := priceTermsFromMetadata(price.Metadata)
+ if err != nil || priceTerms != terms {
+ return "", errBillingUnavailable
+ }
+ if len(price.CurrencyOptions) != 0 || price.BillingScheme != "per_unit" || (len(price.TransformQuantity) > 0 && string(price.TransformQuantity) != "null") || price.Metadata["plan"] != plan || price.Metadata["band"] != band || price.ID != priceID || !price.Active || price.LiveMode != b.cfg.LiveMode || price.Currency != string(terms.Money()) || price.TaxBehavior != "exclusive" || price.Recurring.Interval != "year" || price.Recurring.IntervalCount != 1 || price.Recurring.UsageType != "licensed" || price.UnitAmount != quote.UnitMinor {
return "", errBillingUnavailable
}
product, err := b.client.GetProduct(ctx, price.ProductID)
@@ -245,7 +325,7 @@ func (s *Server) startBillingCheckout(ctx context.Context, acct *Account, plan,
return "", err
}
}
- request := billingIntent{AccountID: acct.ID, Plan: plan, Terms: quote.TermsVersion, Band: band, Price: priceID, Quantity: qty, Customer: state.Customer, Coupon: coupon, Country: country, LocDigest: b.locationDigest(country, postal), LiveMode: b.cfg.LiveMode, Members: int64(len(members))}
+ request := billingIntent{AccountID: acct.ID, Plan: plan, Terms: terms.TermsVersion, PriceBook: terms.PriceBook, Currency: string(terms.Currency), Band: band, Price: priceID, Quantity: qty, Customer: state.Customer, Coupon: coupon, Country: country, LocDigest: b.locationDigest(country, postal), LiveMode: b.cfg.LiveMode, Members: int64(len(members))}
previous, lookupErr := s.store.billingCheckoutIntent(acct.ID)
if lookupErr != nil && !errors.Is(lookupErr, sql.ErrNoRows) {
return "", lookupErr
@@ -278,8 +358,8 @@ func (s *Server) startBillingCheckout(ctx context.Context, acct *Account, plan,
if session.ID != previous.SessionID || session.Metadata["checkout_intent"] != previous.ID || session.ClientReferenceID != acct.Sqid || session.LiveMode != b.cfg.LiveMode {
return "", errBillingIdentity
}
- if session.Status == "open" && (previous.Plan != request.Plan || previous.Band != request.Band || previous.Quantity != request.Quantity || previous.Country != request.Country || previous.Price != request.Price || previous.Coupon != request.Coupon || previous.LocDigest != request.LocDigest || previous.LiveMode != request.LiveMode || previous.Members != request.Members) {
- // A bound parameter changed — including a corrected postal code,
+ if session.Status == "open" && (previous.Currency != request.Currency || previous.PriceBook != request.PriceBook || previous.Plan != request.Plan || previous.Band != request.Band || previous.Quantity != request.Quantity || previous.Country != request.Country || previous.Price != request.Price || previous.Coupon != request.Coupon || previous.LocDigest != request.LocDigest || previous.LiveMode != request.LiveMode || previous.Members != request.Members) {
+ // A bound parameter changed — including the currency, a corrected postal code,
// which the location digest captures without persisting the code
// itself, and a member snapshot that no longer matches the account
// (or predates snapshots). Authoritatively expire the open session BEFORE minting a
@@ -350,7 +430,7 @@ func (s *Server) startBillingCheckout(ctx context.Context, acct *Account, plan,
return "", err
}
back := s.cfg.Origin + "/a/" + acct.Sqid + "/billing"
- session, err := b.client.CreateCheckout(ctx, billingstripe.CreateCheckoutParams{PriceID: intent.Price, Quantity: intent.Quantity, SuccessURL: back + "?pending=1", CancelURL: back, CustomerID: intent.Customer, ClientReferenceID: acct.Sqid, Metadata: billingstripe.CheckoutMetadata{AccountSqid: acct.Sqid, TermsVersion: intent.Terms, Plan: intent.Plan, Band: intent.Band, IntentID: intent.ID}, HouseCoupon: intent.Coupon, LocalizeCurrency: true}, "carloku-checkout-"+intent.ID)
+ session, err := b.client.CreateCheckout(ctx, billingstripe.CreateCheckoutParams{PriceID: intent.Price, Quantity: intent.Quantity, SuccessURL: back + "?pending=1", CancelURL: back, CustomerID: intent.Customer, ClientReferenceID: acct.Sqid, Metadata: billingstripe.CheckoutMetadata{AccountSqid: acct.Sqid, TermsVersion: intent.Terms, Plan: intent.Plan, Band: intent.Band, IntentID: intent.ID, PriceBook: intent.PriceBook, Currency: intent.Currency}, HouseCoupon: intent.Coupon, Currency: string(intent.priceTerms().Money())}, "carloku-checkout-"+intent.ID)
if err != nil {
return "", err
}
@@ -424,10 +504,26 @@ func (s *Server) reconcileBillingSubscription(ctx context.Context, id, eventID s
return errBillingIdentity
}
plan, terms, band := sub.Metadata["plan"], sub.Metadata["terms_version"], sub.Metadata["band"]
- if _, err := plans.Quote(terms, plan, band, sub.Items[0].Quantity); err != nil {
+ // The subscription says which book and currency it was bought under;
+ // one that says neither is from before books, which is EUR. Its price
+ // must be the one configured for exactly that plan, band, book and
+ // currency, and the subscription itself must be in that currency: a
+ // subscription whose money is not what its metadata says is not
+ // recorded as anything.
+ priceTerms, err := priceTermsFromMetadata(sub.Metadata)
+ if err != nil {
+ if errors.Is(err, plans.ErrUnknownTermsVersion) {
+ return err
+ }
+ return errBillingIdentity
+ }
+ if _, err := plans.PriceFor(priceTerms, plan, band, sub.Items[0].Quantity); err != nil {
return err
}
- if plan == plans.PlanFree || sub.Items[0].PriceID != b.cfg.Prices[billingPriceKey(terms, plan, band)] {
+ if plan == plans.PlanFree || sub.Items[0].PriceID == "" || sub.Items[0].PriceID != b.cfg.Prices[billingPriceKey(priceTerms, plan, band)] {
+ return errBillingIdentity
+ }
+ if !billingCurrencyAgrees(priceTerms, sub.Currency) || !billingCurrencyAgrees(priceTerms, sub.Items[0].PriceCurrency) {
return errBillingIdentity
}
switch sub.Status {
@@ -447,7 +543,7 @@ func (s *Server) reconcileBillingSubscription(ctx context.Context, id, eventID s
if err != nil {
return err
}
- if intent.ID != sub.Metadata["checkout_intent"] || intent.Plan != plan || intent.Terms != terms || intent.Band != band || intent.Quantity != sub.Items[0].Quantity {
+ if intent.ID != sub.Metadata["checkout_intent"] || intent.Plan != plan || intent.Terms != terms || intent.Band != band || intent.Quantity != sub.Items[0].Quantity || intent.priceTerms() != priceTerms {
return errBillingIdentity
}
if intent.SessionID == "" {
@@ -468,6 +564,11 @@ func (s *Server) reconcileBillingSubscription(ctx context.Context, id, eventID s
if session.LiveMode != b.cfg.LiveMode || session.SubscriptionID != id || session.CustomerID != sub.CustomerID || session.ClientReferenceID != acct.Sqid || session.Metadata["checkout_intent"] != intent.ID {
return errBillingIdentity
}
+ // A purchase priced from a book was paid in the book's currency for
+ // the buyer's choice, with no conversion on the session.
+ if !priceTerms.Legacy() && !session.FixedCurrency(string(priceTerms.Currency)) {
+ return errBillingIdentity
+ }
if session.PaymentStatus != "paid" && session.PaymentStatus != "no_payment_required" {
return errBillingPending
}
@@ -486,7 +587,7 @@ func (s *Server) reconcileBillingSubscription(ctx context.Context, id, eventID s
} else if state.Subscription != id || state.Customer != sub.CustomerID {
return errBillingIdentity
}
- next := billingState{ReviewReason: reviewReason, IntentID: sub.Metadata["checkout_intent"], AccountID: acct.ID, Customer: sub.CustomerID, Subscription: id, Status: sub.Status, Plan: plan, Terms: terms, Band: band, Quantity: sub.Items[0].Quantity, PeriodStart: sub.CurrentPeriodStart.Unix(), PeriodEnd: sub.CurrentPeriodEnd.Unix(), LiveMode: sub.LiveMode}
+ next := billingState{ReviewReason: reviewReason, IntentID: sub.Metadata["checkout_intent"], AccountID: acct.ID, Customer: sub.CustomerID, Subscription: id, Status: sub.Status, Plan: plan, Terms: terms, PriceBook: priceTerms.PriceBook, Currency: string(priceTerms.Currency), Band: band, Quantity: sub.Items[0].Quantity, PeriodStart: sub.CurrentPeriodStart.Unix(), PeriodEnd: sub.CurrentPeriodEnd.Unix(), LiveMode: sub.LiveMode}
effective := now
if state.Subscription != id && !sub.Created.After(now) && !sub.Created.IsZero() {
effective = sub.Created
@@ -611,6 +712,9 @@ func (s *Server) bindBillingCheckout(ctx context.Context, sessionID string) (str
if intent.ID != session.Metadata["checkout_intent"] || intent.Plan != session.Metadata["plan"] || intent.Terms != session.Metadata["terms_version"] || intent.Band != session.Metadata["band"] {
return "", errBillingIdentity
}
+ if sessionTerms, err := priceTermsFromMetadata(session.Metadata); err != nil || sessionTerms != intent.priceTerms() || (!sessionTerms.Legacy() && !session.FixedCurrency(string(sessionTerms.Currency))) {
+ return "", errBillingIdentity
+ }
r, err := s.store.db.Exec(`UPDATE billing_checkout_intents SET session_id=?,expires_at=? WHERE account_id=? AND id=? AND (session_id='' OR session_id=?)`, session.ID, session.ExpiresAt, acct.ID, intent.ID, session.ID)
if err != nil {
return "", err
@@ -640,7 +744,7 @@ func (s *Store) billingSubscriptionAccounts() ([]billingState, error) {
var out []billingState
for rows.Next() {
var x billingState
- if err := rows.Scan(&x.AccountID, &x.Revision, &x.House, &x.Customer, &x.Subscription, &x.Status, &x.Plan, &x.Terms, &x.Band, &x.Quantity, &x.PeriodStart, &x.PeriodEnd); err != nil {
+ if err := rows.Scan(&x.AccountID, &x.Revision, &x.House, &x.Customer, &x.Subscription, &x.Status, &x.Plan, &x.Terms, &x.Band, &x.Quantity, &x.PeriodStart, &x.PeriodEnd, &x.Currency, &x.PriceBook); err != nil {
return nil, err
}
out = append(out, x)
@@ -659,7 +763,7 @@ func billingPublicError(err error) string {
}
func billingDiagnostic(err error) string {
- for _, known := range []error{errBillingUnavailable, errBillingPending, errBillingIdentity, errBillingConflict, errBillingRetirement, errEntitlementAuthority} {
+ for _, known := range []error{errBillingUnavailable, errBillingPending, errBillingIdentity, errBillingConflict, errBillingRetirement, errEntitlementAuthority, errBillingCurrency} {
if errors.Is(err, known) {
return known.Error()
}
@@ -796,7 +900,7 @@ func (s *Server) recoverReadyIntentSession(ctx context.Context, acct *Account, i
// they never POST; they rely on the read-only listing below instead.
if allowCreate && within() {
back := s.cfg.Origin + "/a/" + acct.Sqid + "/billing"
- session, err := b.client.CreateCheckout(ctx, billingstripe.CreateCheckoutParams{PriceID: intent.Price, Quantity: intent.Quantity, SuccessURL: back + "?pending=1", CancelURL: back, CustomerID: intent.Customer, ClientReferenceID: acct.Sqid, Metadata: billingstripe.CheckoutMetadata{AccountSqid: acct.Sqid, TermsVersion: intent.Terms, Plan: intent.Plan, Band: intent.Band, IntentID: intent.ID}, HouseCoupon: intent.Coupon, LocalizeCurrency: true}, "carloku-checkout-"+intent.ID)
+ session, err := b.client.CreateCheckout(ctx, billingstripe.CreateCheckoutParams{PriceID: intent.Price, Quantity: intent.Quantity, SuccessURL: back + "?pending=1", CancelURL: back, CustomerID: intent.Customer, ClientReferenceID: acct.Sqid, Metadata: billingstripe.CheckoutMetadata{AccountSqid: acct.Sqid, TermsVersion: intent.Terms, Plan: intent.Plan, Band: intent.Band, IntentID: intent.ID, PriceBook: intent.PriceBook, Currency: intent.Currency}, HouseCoupon: intent.Coupon, Currency: string(intent.priceTerms().Money())}, "carloku-checkout-"+intent.ID)
if err == nil {
if session.ID == "" || session.LiveMode != b.cfg.LiveMode || session.Metadata["checkout_intent"] != intent.ID || session.ClientReferenceID != acct.Sqid {
return nil, errBillingIdentity
diff --git a/internal/console/billingservice_test.go b/internal/console/billingservice_test.go
index 9c5860a67..7046b6164 100644
--- a/internal/console/billingservice_test.go
+++ b/internal/console/billingservice_test.go
@@ -38,6 +38,46 @@ type fakeBillingStripe struct {
postals []string
coupon map[string]any
productTaxCode string
+ // currency is what the last checkout request asked for; sessions and
+ // the subscription report it, as the provider does. sessionCurrency
+ // overrides what a session reports, for a provider that answers in
+ // another currency than it was asked for.
+ currency, sessionCurrency string
+ // prices are extra price objects, by id, beside price_personal.
+ prices map[string]map[string]any
+ subscriptionPrice, subscriptionCurrency string
+ subscriptionQuantity int64
+}
+
+// subscriptionPrice, subscriptionCurrency and subscriptionQuantity override
+// what the subscription reports; unset, it is price_personal for one
+// member in the currency the checkout asked for.
+func (f *fakeBillingStripe) subscriptionPriceID() string {
+ if f.subscriptionPrice != "" {
+ return f.subscriptionPrice
+ }
+ return "price_personal"
+}
+
+func (f *fakeBillingStripe) subscriptionMoney() string {
+ if f.subscriptionCurrency != "" {
+ return f.subscriptionCurrency
+ }
+ return f.money()
+}
+
+func (f *fakeBillingStripe) subscriptionQty() int64 {
+ if f.subscriptionQuantity != 0 {
+ return f.subscriptionQuantity
+ }
+ return 1
+}
+
+func (f *fakeBillingStripe) money() string {
+ if f.currency == "" {
+ return "eur"
+ }
+ return f.currency
}
func newBillingFixture(t *testing.T) (*consoleFixture, *fakeBillingStripe, *Account) {
@@ -54,7 +94,7 @@ func newBillingFixture(t *testing.T) (*consoleFixture, *fakeBillingStripe, *Acco
if err != nil {
t.Fatal(err)
}
- fx.srv.billing = &billingRuntime{locks: newStoreWalkLocks(), client: client, cfg: BillingConfig{MerchantAccount: "acct_carloku", TaxCode: "txcd_test", PortalConfiguration: "bpc_safe", CountryBands: map[string]string{"IE": "A"}, Prices: map[string]string{billingPriceKey(plans.CurrentTermsVersion, "personal", "A"): "price_personal"}, WebhookSecrets: []string{"whsec_test"}}}
+ fx.srv.billing = &billingRuntime{locks: newStoreWalkLocks(), client: client, cfg: BillingConfig{MerchantAccount: "acct_carloku", TaxCode: "txcd_test", PortalConfiguration: "bpc_safe", CountryBands: map[string]string{"IE": "A"}, Prices: map[string]string{billingPriceKey(plans.LegacyPriceTerms(plans.CurrentTermsVersion), "personal", "A"): "price_personal"}, WebhookSecrets: []string{"whsec_test"}}}
return fx, f, acct
}
@@ -70,7 +110,11 @@ func (f *fakeBillingStripe) session() map[string]any {
if f.paymentStatus != "" {
payment = f.paymentStatus
}
- return map[string]any{"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"}}}
+ currency := f.money()
+ if f.sessionCurrency != "" {
+ currency = f.sessionCurrency
+ }
+ 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"}}}
}
func (f *fakeBillingStripe) serve(w http.ResponseWriter, r *http.Request) {
@@ -97,6 +141,8 @@ func (f *fakeBillingStripe) serve(w http.ResponseWriter, r *http.Request) {
f.postals = append(f.postals, r.PostForm.Get("address[postal_code]"))
}
out = map[string]any{"id": "cus_paid", "livemode": false, "address": map[string]string{"country": f.customerCountry}, "shipping": nil, "metadata": map[string]string{"account_sqid": f.sqid}, "tax": map[string]any{"automatic_tax": "supported", "location": map[string]string{"country": f.customerCountry}}}
+ case strings.HasPrefix(r.URL.Path, "/v1/prices/") && f.prices[strings.TrimPrefix(r.URL.Path, "/v1/prices/")] != nil:
+ out = f.prices[strings.TrimPrefix(r.URL.Path, "/v1/prices/")]
case r.URL.Path == "/v1/prices/price_personal":
out = map[string]any{"id": "price_personal", "product": "prod_personal", "active": true, "livemode": false, "currency": "eur", "unit_amount": f.priceCents, "tax_behavior": "exclusive", "billing_scheme": "per_unit", "metadata": map[string]string{"terms_version": plans.CurrentTermsVersion, "plan": "personal", "band": "A"}, "recurring": map[string]any{"interval": "year", "interval_count": 1, "usage_type": "licensed"}}
case r.URL.Path == "/v1/tax/registrations":
@@ -118,6 +164,12 @@ func (f *fakeBillingStripe) serve(w http.ResponseWriter, r *http.Request) {
for _, key := range []string{"account_sqid", "terms_version", "plan", "band", "checkout_intent"} {
f.meta[key] = r.PostForm.Get("subscription_data[metadata][" + key + "]")
}
+ for _, key := range []string{"price_book", "currency"} {
+ if v := r.PostForm.Get("subscription_data[metadata][" + key + "]"); v != "" {
+ f.meta[key] = v
+ }
+ }
+ f.currency = r.PostForm.Get("currency")
if f.failCreate {
f.failCreate = false
w.WriteHeader(http.StatusInternalServerError)
@@ -136,7 +188,7 @@ func (f *fakeBillingStripe) serve(w http.ResponseWriter, r *http.Request) {
out = map[string]any{"id": "coupon_house", "valid": true, "livemode": false, "percent_off": 100, "duration": "forever"}
}
case r.URL.Path == "/v1/subscriptions/sub_paid":
- out = map[string]any{"id": "sub_paid", "customer": "cus_paid", "status": f.subscriptionStatus, "livemode": false, "created": f.now.Unix(), "current_period_start": f.now.Unix(), "current_period_end": f.now.AddDate(1, 0, 0).Unix(), "metadata": f.meta, "items": map[string]any{"data": []map[string]any{{"id": "si_paid", "price": map[string]string{"id": "price_personal"}, "quantity": 1}}, "has_more": false}}
+ out = map[string]any{"id": "sub_paid", "customer": "cus_paid", "status": f.subscriptionStatus, "livemode": false, "created": f.now.Unix(), "current_period_start": f.now.Unix(), "current_period_end": f.now.AddDate(1, 0, 0).Unix(), "metadata": f.meta, "currency": f.subscriptionMoney(), "items": map[string]any{"data": []map[string]any{{"id": "si_paid", "price": map[string]string{"id": f.subscriptionPriceID(), "currency": f.subscriptionMoney()}, "quantity": f.subscriptionQty()}}, "has_more": false}}
default:
w.WriteHeader(http.StatusNotFound)
return
@@ -147,11 +199,11 @@ func (f *fakeBillingStripe) serve(w http.ResponseWriter, r *http.Request) {
func TestBillingCheckoutRetryDoesNotDuplicateSubscription(t *testing.T) {
fx, f, acct := newBillingFixture(t)
ctx := context.Background()
- first, err := fx.srv.startBillingCheckout(ctx, acct, "personal", "IE", "", f.now)
+ first, err := fx.srv.startBillingCheckout(ctx, acct, "personal", "IE", "", "", f.now)
if err != nil {
t.Fatal(err)
}
- second, err := fx.srv.startBillingCheckout(ctx, acct, "personal", "IE", "", f.now.Add(time.Minute))
+ second, err := fx.srv.startBillingCheckout(ctx, acct, "personal", "IE", "", "", f.now.Add(time.Minute))
if err != nil {
t.Fatal(err)
}
@@ -180,7 +232,7 @@ func TestBillingCheckoutRequiresConfiguredPlanTaxCode(t *testing.T) {
plans.PlanBusiness: "txcd_business",
}
f.productTaxCode = "txcd_personal"
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "IE", "", f.now); err != nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "IE", "", "", f.now); err != nil {
t.Fatalf("matching personal code: %v", err)
}
@@ -192,7 +244,7 @@ func TestBillingCheckoutRequiresConfiguredPlanTaxCode(t *testing.T) {
plans.PlanBusiness: "txcd_business",
}
f.productTaxCode = "txcd_business"
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "IE", "", f.now); !errors.Is(err, errBillingUnavailable) {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "IE", "", "", f.now); !errors.Is(err, errBillingUnavailable) {
t.Fatalf("cross-plan product code: %v", err)
}
if f.checkoutCalls != 0 {
@@ -203,10 +255,10 @@ func TestBillingCheckoutRequiresConfiguredPlanTaxCode(t *testing.T) {
func TestBillingCheckoutAmbiguousResponseUsesSameKey(t *testing.T) {
fx, f, acct := newBillingFixture(t)
f.failCreate = true
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now); err == nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", f.now); err == nil {
t.Fatal("expected ambiguous failure")
}
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now.Add(time.Minute)); err != nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", f.now.Add(time.Minute)); err != nil {
t.Fatal(err)
}
if len(f.keys) != 2 || f.keys[0] != f.keys[1] {
@@ -223,7 +275,7 @@ func TestBillingCheckoutRejectsWrongPriceAndMissingTax(t *testing.T) {
} else {
f.taxReady = false
}
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now); err == nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", f.now); err == nil {
t.Fatal("accepted invalid configuration")
}
if f.checkoutCalls != 0 {
@@ -235,7 +287,7 @@ func TestBillingCheckoutRejectsWrongPriceAndMissingTax(t *testing.T) {
func TestBillingWebhookAuthoritativeStateAndReplay(t *testing.T) {
fx, f, acct := newBillingFixture(t)
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now); err != nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", f.now); err != nil {
t.Fatal(err)
}
f.mu.Lock()
@@ -313,7 +365,7 @@ func TestBillingPassRecoversCompletedCheckoutWithoutWebhook(t *testing.T) {
t.Run(fmt.Sprint("lost_response_", lost), func(t *testing.T) {
fx, f, acct := newBillingFixture(t)
f.failCreate = lost
- _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now)
+ _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", f.now)
if (err != nil) != lost {
t.Fatalf("create: %v", err)
}
@@ -354,7 +406,7 @@ func TestBillingPassRecoversCompletedCheckoutWithoutWebhook(t *testing.T) {
func TestBillingTaxLocationValidatedBeforePayment(t *testing.T) {
fx, f, acct := newBillingFixture(t)
f.customerCountry = "US"
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now); err == nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", f.now); err == nil {
t.Fatal("mismatched tax location accepted")
}
if f.checkoutCalls != 0 {
@@ -364,7 +416,7 @@ func TestBillingTaxLocationValidatedBeforePayment(t *testing.T) {
func TestBillingPaidCountryChangeDoesNotStrandBuyer(t *testing.T) {
fx, f, acct := newBillingFixture(t)
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now); err != nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", f.now); err != nil {
t.Fatal(err)
}
f.mu.Lock()
@@ -389,7 +441,7 @@ func TestBillingMissingRequiredPostalCodeMakesNoStripeCall(t *testing.T) {
t.Run(country, func(t *testing.T) {
fx, f, acct := newBillingFixture(t)
fx.srv.billing.cfg.CountryBands[country] = "A"
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", country, "", f.now); err == nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", country, "", "", f.now); err == nil {
t.Fatal("accepted missing required postal code")
}
if f.requests != 0 {
@@ -465,7 +517,7 @@ func TestBillingHouseCheckoutActivatesWithoutPaymentIntent(t *testing.T) {
if _, err := fx.st.db.Exec(`INSERT INTO billing_accounts(account_id,house) VALUES(?,1)`, acct.ID); err != nil {
t.Fatal(err)
}
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now); err != nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", f.now); err != nil {
t.Fatal(err)
}
if f.forms[0].Get("discounts[0][coupon]") != "coupon_house" || f.forms[0].Get("allow_promotion_codes") != "" {
@@ -509,11 +561,11 @@ func TestBillingCheckoutPrepaymentFailureAllowsCorrectedRequest(t *testing.T) {
f.customerCountry = "FR" // Stripe resolves the customer to FR
// The first attempt claims IE; verification fails because the customer is
// FR, leaving an intent with no session and customer_ready=0.
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now); err == nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", f.now); err == nil {
t.Fatal("expected prepayment identity failure")
}
// The corrected request (FR) must not be wedged behind the stillborn intent.
- dest, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "FR", "", f.now.Add(time.Minute))
+ dest, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "FR", "", "", f.now.Add(time.Minute))
if err != nil {
t.Fatalf("corrected request rejected: %v", err)
}
@@ -527,7 +579,7 @@ func TestBillingCheckoutPrepaymentFailureAllowsCorrectedRequest(t *testing.T) {
// retirement reservation succeeds.
func TestBillingExpiredCheckoutDoesNotBlockRetirement(t *testing.T) {
fx, f, acct := newBillingFixture(t)
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now); err != nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", f.now); err != nil {
t.Fatal(err)
}
if err := fx.st.reserveBillingRetirement(acct.ID, f.now); !errBillingRetirementIs(err) {
@@ -554,10 +606,10 @@ func TestBillingCorrectedPostalExpiresSessionAndUpdatesCustomer(t *testing.T) {
fx, f, acct := newBillingFixture(t)
fx.srv.billing.cfg.CountryBands["CA"] = "A"
f.customerCountry = "CA"
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "CA", "A1A 1A1", f.now); err != nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "CA", "A1A 1A1", "", f.now); err != nil {
t.Fatal(err)
}
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "CA", "B2B 2B2", f.now.Add(time.Minute)); err != nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "CA", "B2B 2B2", "", f.now.Add(time.Minute)); err != nil {
t.Fatalf("corrected postal rejected: %v", err)
}
if f.expireCalls != 1 {
@@ -583,7 +635,7 @@ func TestBillingCorrectedPostalExpiresSessionAndUpdatesCustomer(t *testing.T) {
func TestBillingLostResponseBindingIsPendingNotRejected(t *testing.T) {
fx, f, acct := newBillingFixture(t)
f.failCreate = true
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now); err == nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", f.now); err == nil {
t.Fatal("expected ambiguous create failure")
}
f.mu.Lock()
@@ -618,7 +670,7 @@ func TestBillingHouseCouponMustBe100ForeverOrRefused(t *testing.T) {
if _, err := fx.st.db.Exec(`INSERT INTO billing_accounts(account_id,house) VALUES(?,1)`, acct.ID); err != nil {
t.Fatal(err)
}
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now); err == nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", f.now); err == nil {
t.Fatal("accepted an unsafe house coupon")
}
if f.checkoutCalls != 0 {
diff --git a/internal/console/billingstore.go b/internal/console/billingstore.go
index 16953ff83..b07c3270c 100644
--- a/internal/console/billingstore.go
+++ b/internal/console/billingstore.go
@@ -12,6 +12,7 @@ import (
"unicode/utf8"
"github.com/carlosframework/platform/internal/billingstripe"
+ "github.com/carlosframework/platform/internal/plans"
)
var errBillingConflict = errors.New("billing_conflict")
@@ -55,6 +56,31 @@ type billingState struct {
// LiveMode is the verified subscription's provider mode. Only live rows
// can ever be published as entitlements.
LiveMode bool
+ // PriceBook and Currency are what the subscription was bought under
+ // (plans.PriceTerms). Both are empty for a row written before price
+ // books existed, which is EUR under the terms' own figures. They are
+ // set when a subscription is first recorded and never change for it.
+ PriceBook, Currency string
+}
+
+// priceTerms is the commercial identity the row was bought under.
+func (x billingState) priceTerms() plans.PriceTerms {
+ return plans.PriceTerms{TermsVersion: x.Terms, PriceBook: x.PriceBook, Currency: plans.Currency(x.Currency)}
+}
+
+// storedPriceTerms validates a purchase's commercial identity and gives the
+// two columns it is stored in. A purchase under the terms' own EUR figures
+// (no book) is stored with no currency, however it was written: that is
+// what a row from before price books looks like, and there is one spelling
+// of it.
+func storedPriceTerms(t plans.PriceTerms) (book, currency string, err error) {
+ if err := t.Validate(); err != nil {
+ return "", "", err
+ }
+ if t.Legacy() {
+ return "", "", nil
+ }
+ return t.PriceBook, string(t.Currency), nil
}
type billingIntent struct {
@@ -71,6 +97,14 @@ type billingIntent struct {
// against actual membership when the intent is begun.
LiveMode bool
Members int64
+ // PriceBook and Currency are the book the intent was priced from and
+ // the currency the buyer chose. They are fixed when the intent is
+ // created; a request in another currency is another intent.
+ PriceBook, Currency string
+}
+
+func (x billingIntent) priceTerms() plans.PriceTerms {
+ return plans.PriceTerms{TermsVersion: x.Terms, PriceBook: x.PriceBook, Currency: plans.Currency(x.Currency)}
}
func (s *Store) migrateBilling() error {
@@ -149,6 +183,18 @@ func (s *Store) migrateBilling() error {
if err := s.migrateBillingMembership(); err != nil {
return err
}
+ // The price book and currency of a purchase
+ // (2026-10-01-multi-currency-price-book-design.md). Empty in both means
+ // a row from before price books existed: EUR, under the terms' own
+ // figures. Nothing is backfilled, because that is exactly what such a
+ // row is.
+ for _, table := range []string{"billing_accounts", "billing_checkout_intents", "billing_assignments"} {
+ for _, column := range []string{"currency", "price_book"} {
+ if err := s.addColumnIfMissing(table, column, `TEXT NOT NULL DEFAULT ''`); err != nil {
+ return err
+ }
+ }
+ }
if err := s.migrateBillingInvoices(); err != nil {
return err
}
@@ -164,14 +210,14 @@ func (s *Store) migrateBilling() error {
func scanBilling(row interface{ Scan(...any) error }, accountID int64) (billingState, error) {
x := billingState{AccountID: accountID}
err := row.Scan(&x.Revision, &x.House, &x.Customer, &x.Subscription, &x.Status,
- &x.Plan, &x.Terms, &x.Band, &x.Quantity, &x.PeriodStart, &x.PeriodEnd)
+ &x.Plan, &x.Terms, &x.Band, &x.Quantity, &x.PeriodStart, &x.PeriodEnd, &x.Currency, &x.PriceBook)
if errors.Is(err, sql.ErrNoRows) {
return x, nil
}
return x, err
}
-const billingColumns = `revision, house, customer, subscription, status, plan, terms, band, quantity, period_start, period_end`
+const billingColumns = `revision, house, customer, subscription, status, plan, terms, band, quantity, period_start, period_end, currency, price_book`
func (s *Store) billingAccount(id int64) (billingState, error) {
return scanBilling(s.db.QueryRow(`SELECT `+billingColumns+` FROM billing_accounts WHERE account_id=?`, id), id)
@@ -180,11 +226,11 @@ func (s *Store) billingAccount(id int64) (billingState, error) {
func scanBillingIntent(row interface{ Scan(...any) error }) (billingIntent, error) {
var x billingIntent
err := row.Scan(&x.AccountID, &x.ID, &x.Plan, &x.Terms, &x.Band, &x.Price,
- &x.Quantity, &x.CreatedAt, &x.SessionID, &x.URL, &x.ExpiresAt, &x.Customer, &x.Coupon, &x.Country, &x.LocDigest, &x.CustomerReady, &x.LiveMode, &x.Members)
+ &x.Quantity, &x.CreatedAt, &x.SessionID, &x.URL, &x.ExpiresAt, &x.Customer, &x.Coupon, &x.Country, &x.LocDigest, &x.CustomerReady, &x.LiveMode, &x.Members, &x.Currency, &x.PriceBook)
return x, err
}
-const billingIntentColumns = `account_id,id,plan,terms,band,price,quantity,created_at,session_id,url,expires_at,customer,coupon,country,loc_digest,customer_ready,live_mode,members`
+const billingIntentColumns = `account_id,id,plan,terms,band,price,quantity,created_at,session_id,url,expires_at,customer,coupon,country,loc_digest,customer_ready,live_mode,members,currency,price_book`
func (s *Store) billingCheckoutIntent(id int64) (billingIntent, error) {
return scanBillingIntent(s.db.QueryRow(`SELECT `+billingIntentColumns+` FROM billing_checkout_intents WHERE account_id=?`, id))
@@ -194,6 +240,14 @@ func (s *Store) beginBillingCheckout(want billingIntent, now time.Time) (billing
if want.AccountID <= 0 || want.Plan == "" || want.Terms == "" || want.Band == "" || want.Price == "" || want.Quantity <= 0 {
return billingIntent{}, errors.New("billing_intent_invalid")
}
+ // The currency is fixed here, with the intent. A book with no currency,
+ // a currency the book does not sell in, or a currency with no book
+ // other than EUR is not something to open a purchase on.
+ book, currency, err := storedPriceTerms(want.priceTerms())
+ if err != nil {
+ return billingIntent{}, errors.New("billing_intent_invalid")
+ }
+ want.PriceBook, want.Currency = book, currency
var nonce [24]byte
if _, err := rand.Read(nonce[:]); err != nil {
return billingIntent{}, err
@@ -236,8 +290,8 @@ func (s *Store) beginBillingCheckout(want billingIntent, now time.Time) (billing
if err := checkIntentMembershipTx(tx, want); err != nil {
return billingIntent{}, err
}
- _, err = tx.Exec(`INSERT OR IGNORE INTO billing_checkout_intents(account_id,id,plan,terms,band,price,quantity,created_at,customer,coupon,country,loc_digest,live_mode,members)
- VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, want.AccountID, want.ID, want.Plan, want.Terms, want.Band, want.Price, want.Quantity, want.CreatedAt, want.Customer, want.Coupon, want.Country, want.LocDigest, want.LiveMode, want.Members)
+ _, err = tx.Exec(`INSERT OR IGNORE INTO billing_checkout_intents(account_id,id,plan,terms,band,price,quantity,created_at,customer,coupon,country,loc_digest,live_mode,members,currency,price_book)
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, want.AccountID, want.ID, want.Plan, want.Terms, want.Band, want.Price, want.Quantity, want.CreatedAt, want.Customer, want.Coupon, want.Country, want.LocDigest, want.LiveMode, want.Members, want.Currency, want.PriceBook)
if err != nil {
return billingIntent{}, err
}
@@ -245,7 +299,7 @@ func (s *Store) beginBillingCheckout(want billingIntent, now time.Time) (billing
if err != nil {
return billingIntent{}, err
}
- if actual.Plan != want.Plan || actual.Terms != want.Terms || actual.Band != want.Band || actual.Price != want.Price || actual.Quantity != want.Quantity || (want.Customer != "" && actual.Customer != want.Customer) || actual.Coupon != want.Coupon || actual.Country != want.Country || actual.LocDigest != want.LocDigest || actual.LiveMode != want.LiveMode || actual.Members != want.Members {
+ if actual.Currency != want.Currency || actual.PriceBook != want.PriceBook || actual.Plan != want.Plan || actual.Terms != want.Terms || actual.Band != want.Band || actual.Price != want.Price || actual.Quantity != want.Quantity || (want.Customer != "" && actual.Customer != want.Customer) || actual.Coupon != want.Coupon || actual.Country != want.Country || actual.LocDigest != want.LocDigest || actual.LiveMode != want.LiveMode || actual.Members != want.Members {
return billingIntent{}, errBillingConflict
}
if err := tx.Commit(); err != nil {
@@ -390,6 +444,11 @@ func (s *Store) applyBillingStateDated(next billingState, expectedRevision int64
if next.AccountID <= 0 || next.Customer == "" || next.Subscription == "" || next.Quantity <= 0 || next.Plan == "" || next.Terms == "" || next.Band == "" || next.PeriodEnd <= next.PeriodStart {
return errors.New("billing_state_invalid")
}
+ book, currency, err := storedPriceTerms(next.priceTerms())
+ if err != nil {
+ return errors.New("billing_state_invalid")
+ }
+ next.PriceBook, next.Currency = book, currency
tx, err := s.db.Begin()
if err != nil {
return err
@@ -421,6 +480,14 @@ func (s *Store) applyBillingStateDated(next billingState, expectedRevision int64
if current.Revision != expectedRevision {
return errBillingConflict
}
+ // A subscription is in one currency, from one book, for as long as it
+ // exists. A reading of the same subscription that names another is not
+ // a change to record: it is a subscription that is not the one this
+ // row is about, or a provider record somebody altered. Nothing is
+ // written, and nothing already billed is re-read in another currency.
+ if current.Subscription == next.Subscription && current.Plan != "" && (current.Currency != next.Currency || current.PriceBook != next.PriceBook) {
+ return errBillingIdentity
+ }
var currentLive bool
if err := tx.QueryRow(`SELECT live_mode FROM billing_accounts WHERE account_id=?`, next.AccountID).Scan(&currentLive); err != nil {
return err
@@ -501,8 +568,8 @@ func (s *Store) applyBillingStateDated(next billingState, expectedRevision int64
}
return tx.Commit()
}
- r, err := tx.Exec(`UPDATE billing_accounts SET revision=revision+1,customer=?,subscription=?,status=?,plan=?,terms=?,band=?,quantity=?,period_start=?,period_end=?,live_mode=?
- WHERE account_id=? AND revision=? AND (customer='' OR customer=?) AND (subscription='' OR subscription=? OR status IN ('canceled','incomplete_expired'))`, next.Customer, next.Subscription, next.Status, next.Plan, next.Terms, next.Band, next.Quantity, next.PeriodStart, next.PeriodEnd, next.LiveMode, next.AccountID, expectedRevision, next.Customer, next.Subscription)
+ r, err := tx.Exec(`UPDATE billing_accounts SET revision=revision+1,customer=?,subscription=?,status=?,plan=?,terms=?,band=?,quantity=?,period_start=?,period_end=?,live_mode=?,currency=?,price_book=?
+ WHERE account_id=? AND revision=? AND (customer='' OR customer=?) AND (subscription='' OR subscription=? OR status IN ('canceled','incomplete_expired'))`, next.Customer, next.Subscription, next.Status, next.Plan, next.Terms, next.Band, next.Quantity, next.PeriodStart, next.PeriodEnd, next.LiveMode, next.Currency, next.PriceBook, next.AccountID, expectedRevision, next.Customer, next.Subscription)
if err != nil {
return err
}
@@ -538,8 +605,8 @@ func (s *Store) applyBillingStateDated(next billingState, expectedRevision int64
}
}
}
- _, err = tx.Exec(`INSERT INTO billing_assignments(account_id,revision,customer,subscription,effective_at,plan,terms,band,quantity,house,status,period_start,period_end,live_mode)
- SELECT account_id,revision,customer,subscription,?,plan,terms,band,quantity,house,status,period_start,period_end,live_mode FROM billing_accounts WHERE account_id=?`, assignedAt.Unix(), next.AccountID)
+ _, err = tx.Exec(`INSERT INTO billing_assignments(account_id,revision,customer,subscription,effective_at,plan,terms,band,quantity,house,status,period_start,period_end,live_mode,currency,price_book)
+ SELECT account_id,revision,customer,subscription,?,plan,terms,band,quantity,house,status,period_start,period_end,live_mode,currency,price_book FROM billing_accounts WHERE account_id=?`, assignedAt.Unix(), next.AccountID)
if err != nil {
return err
}
diff --git a/internal/console/entitlementpass_test.go b/internal/console/entitlementpass_test.go
index c067612b3..79d6d36fd 100644
--- a/internal/console/entitlementpass_test.go
+++ b/internal/console/entitlementpass_test.go
@@ -474,7 +474,7 @@ func TestBillingLiveCheckoutRequiresEntitlementAuthority(t *testing.T) {
fx, f, acct := newBillingFixture(t)
bucket := newCountingEntitlementStore(release.NewDirStore(t.TempDir()))
fx.srv.cfg.Releases = bucket
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now); err != nil {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", f.now); err != nil {
t.Fatal(err)
}
if bucket.count() != 0 {
@@ -488,11 +488,11 @@ func TestBillingLiveCheckoutRequiresEntitlementAuthority(t *testing.T) {
if _, err := bucket.Store.Put(context.Background(), entitlementAuthorityKey, []byte(`{"schema":1,"authority":"ffffffffffffffffffffffffffffffff"}`), ""); err != nil {
t.Fatal(err)
}
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now); !errors.Is(err, errEntitlementAuthority) {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", f.now); !errors.Is(err, errEntitlementAuthority) {
t.Fatalf("wrong authority: %v", err)
}
fx.srv.cfg.Releases = nil
- if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now); !errors.Is(err, errEntitlementAuthority) {
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", f.now); !errors.Is(err, errEntitlementAuthority) {
t.Fatalf("missing releases: %v", err)
}
if f.requests != 0 {
@@ -502,7 +502,7 @@ func TestBillingLiveCheckoutRequiresEntitlementAuthority(t *testing.T) {
// With the claim free, a live checkout claims it for this database before
// Stripe (the fake then refuses on provider mode, which is fine here).
fx.srv.cfg.Releases = release.NewDirStore(t.TempDir())
- _, _ = fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now)
+ _, _ = fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", "", f.now)
authority, err := readEntitlementAuthority(fx.st.db)
if err != nil {
t.Fatal(err)
diff --git a/internal/console/server.go b/internal/console/server.go
index cded07cf9..643aee996 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1317,6 +1317,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, s.cfg.Origin+redirectTarget(r.URL), code)
return
}
+ s.rememberCurrency(w, r)
s.mux.ServeHTTP(w, r)
}