The pricing page's billing country survives sign-in beside its currency
A valid ?country= on any GET is kept in carloku_country, as ?currency= is kept in carloku_currency: two upper-case letters in the deployment's band table, one hour, a suggestion. The billing page quotes the country its own form names, else the cookie's, else none, so a visitor who chose India and pounds on the pricing page sees band D in pounds after the sign-in at Keymail, and the form's own choice replaces the link's. Checkout still prices from the posted country and currency alone.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 files changed,
+167
−25
docs/superpowers/specs/2026-10-01-multi-currency-price-book-design.md+6 −5internal/console/billingcurrency.go+54 −18internal/console/billingcurrency_test.go+100 −0internal/console/billingpage.go+7 −2
CI passed — run details
diff --git a/docs/superpowers/specs/2026-10-01-multi-currency-price-book-design.md b/docs/superpowers/specs/2026-10-01-multi-currency-price-book-design.md| index 57c9f43a9..532568cb7 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 |
| @@ -157,9 +157,9 @@ New columns, both `TEXT NOT NULL DEFAULT ''`, on `billing_checkout_intents` and |
| ## Carrying a choice through sign-in |
| -The pricing page links to the console with `?currency=<code>`. The console accepts it on any GET, validates it, and keeps 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. |
| +The pricing page links to the console with `?currency=<code>` and, when the visitor picked a billing country there, `&country=<ISO2>`. The console accepts both on any GET, validates each, and keeps each in a short-lived cookie (`carloku_currency`, `carloku_country`; host-only, `SameSite=Lax`, one hour). An invalid value is dropped and no cookie is set: a currency must be one the book sells in, a country must be two upper-case letters in the deployment's band table, as the pricing page spells it. The billing page preselects the currency from its cookie, else from nothing; it quotes the country its own form names, else the country cookie's, else none: the country the owner then picks decides the default currency. The cookies are suggestions; only the `currency` and `country` fields of the checkout form are choices, and checkout reprices from them. No geo lookup, no external service. |
| -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`. |
| +The billing page (`billingpage.go`, built on the layout branch's template): `s.billingCurrencies()` gives the currencies to offer, `s.suggestedCurrency(r)` the one to preselect (may be empty), `s.suggestedCountry(r)` the country to quote when the form names none, the prices come from `plans.PriceFor`, and the checkout form posts `currency` beside `country`. |
| ## Public export for the site |
| @@ -196,7 +196,7 @@ Interface for the billing page (the layout branch owns the template and the view |
| } |
| ``` |
| -`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 the plan's own figure. For a plan with `band_scaled` true, a band's pooled allowance is the figure times the quantity times the band factor, divided by 10000, rounded down. For the free plan (`band_scaled` false) the band changes nothing. A micro is one millionth of the currency's major unit. Keys are sorted and the output is byte-stable. |
| +`country_currency` lists only countries whose default is not `default_currency`. `country_band` is every country the book sells to; the site puts each country's band on its option, prices the plan cards at that band in the chosen currency, and scales the per-member inclusions it shows by the rule below, so a visitor sees the same figures the console quotes for that country. `plans` is in display order: free, personal, community, business; free has zero prices. `resources_per_member` is the plan's own figure. For a plan with `band_scaled` true, a band's pooled allowance is the figure times the quantity times the band factor, divided by 10000, rounded down. For the free plan (`band_scaled` false) the band changes nothing. 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 |
| @@ -206,12 +206,13 @@ Interface for the billing page (the layout branch owns the template and the view |
| - `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. |
| +- `internal/console/billingcurrency.go`: the two cookies and the list of currencies offered. |
| +- `internal/console/billingpage.go` and `templates/billing.html`: the currency control in the approved words, prices in the page's currency at the quoted country's band, the checkout form posting the currency it shows. |
| - `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`. |
| +`startBillingCheckout` gained a `currency` argument before `now`. The site (carloku-paid-pricing, `copy-review/pricing/build.mjs` and `src/site.js`) renders every amount from the export: the static page is the default currency at band A, the controls apply the currency and the country's band, and the signup links carry both. |
| ## What is not decided or not provable here |
diff --git a/internal/console/billingcurrency.go b/internal/console/billingcurrency.go| index 9879a17fa..be10c8200 100644 |
| --- a/internal/console/billingcurrency.go |
| +++ b/internal/console/billingcurrency.go |
| @@ -2,45 +2,81 @@ package console |
| import ( |
| "net/http" |
| + "strings" |
| "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). |
| +// A buyer's choice of currency and billing country, 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 |
| +// The pricing page links to the console with ?currency=<code> and, when |
| +// the visitor picked a billing country there, &country=<ISO2>. 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. |
| +// value on any GET is kept in a short-lived cookie, one per choice, and |
| +// the billing page reads them to preselect the currency and to quote the |
| +// country's band. |
| // |
| -// 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. |
| +// The cookies are suggestions and nothing more. The purchase's currency |
| +// and country are the "currency" and "country" fields of the checkout |
| +// form, validated when it is posted (startBillingCheckout): a cookie |
| +// cannot choose either, and a value that is not a currency sold or a |
| +// country priced is dropped here, never stored and never turned into a |
| +// default. There is no lookup of where the visitor is. |
| const ( |
| currencyCookie = "carloku_currency" |
| + countryCookie = "carloku_country" |
| 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. |
| +// rememberCurrency keeps a valid ?currency= and a valid ?country= from a |
| +// GET in their cookies. It is called for every request before routing; a |
| +// request with no such parameter, or with one that is not a currency sold |
| +// or a country priced, sets nothing for it. |
| 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 |
| + q := r.URL.Query() |
| + if values, ok := q["currency"]; ok && len(values) == 1 { |
| + if c, ok := s.soldCurrency(values[0]); ok { |
| + s.setCookie(w, currencyCookie, c, int(currencyCookieTTL.Seconds())) |
| + } |
| + } |
| + if values, ok := q["country"]; ok && len(values) == 1 { |
| + if iso, ok := s.pricedCountry(values[0]); ok { |
| + s.setCookie(w, countryCookie, iso, int(currencyCookieTTL.Seconds())) |
| + } |
| + } |
| +} |
| + |
| +// pricedCountry reports whether code is a country the console prices, |
| +// exactly as the pricing page's link spells it: two upper-case letters in |
| +// the deployment's band table. It is empty when billing is switched off. |
| +func (s *Server) pricedCountry(code string) (string, bool) { |
| + if s.billing == nil || len(code) != 2 || code != strings.ToUpper(code) || strings.ToLower(code) == code { |
| + return "", false |
| + } |
| + if _, ok := s.billing.cfg.CountryBands[code]; !ok { |
| + return "", false |
| } |
| - if c, ok := s.soldCurrency(values[0]); ok { |
| - s.setCookie(w, currencyCookie, c, int(currencyCookieTTL.Seconds())) |
| + return code, true |
| +} |
| + |
| +// suggestedCountry is the billing country to quote on the billing page |
| +// when its own form has not named one: the one the visitor arrived with, |
| +// if it is still one the console prices, or none. |
| +func (s *Server) suggestedCountry(r *http.Request) string { |
| + c, err := r.Cookie(countryCookie) |
| + if err != nil { |
| + return "" |
| } |
| + iso, _ := s.pricedCountry(c.Value) |
| + return iso |
| } |
| // soldCurrency reports whether code is a currency the console sells in: |
diff --git a/internal/console/billingcurrency_test.go b/internal/console/billingcurrency_test.go| index 4847ac16c..d72fd6843 100644 |
| --- a/internal/console/billingcurrency_test.go |
| +++ b/internal/console/billingcurrency_test.go |
| @@ -1717,3 +1717,103 @@ func TestBillingPagePostsTheCurrencyItShows(t *testing.T) { |
| t.Fatalf("the session was asked for in %q", got) |
| } |
| } |
| + |
| +// The pricing page's link carries the visitor's currency and billing |
| +// country. Both survive the sign-in at Keymail and reach the billing page, |
| +// which preselects the currency and quotes the country's band in it. The |
| +// page's own form then wins over what the link carried, and refreshes it. |
| +func TestPricingLinkSurvivesSignIn(t *testing.T) { |
| + fx, _, _ := newCurrencyFixture(t) |
| + c := newBrowser(t) |
| + // The console's front page, as the pricing page links to it, before |
| + // any sign-in. |
| + get(t, c, fx.ts.URL+"/?plan=personal¤cy=gbp&country=IN") |
| + signUp(t, fx, c, "code-"+t.Name()) |
| + status, _, body := get(t, c, fx.u("/billing")) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET /billing = %d", status) |
| + } |
| + m := mainOf(t, body) |
| + for _, want := range []string{ |
| + `<option value="gbp" selected>GBP</option>`, `<input type="hidden" name="currency" value="gbp">`, |
| + `<option value="IN" selected>India</option>`, |
| + "Prices for India are 35% of the base price, and so are the monthly inclusions.", |
| + "<strong>£7.70</strong> a year", "<strong>£1.58</strong> per member a year", "<strong>£15.75</strong> per member a year", |
| + } { |
| + if !strings.Contains(m, want) { |
| + t.Fatalf("billing page after the pricing page's link is missing %q in:\n%s", want, m) |
| + } |
| + } |
| + if n := strings.Count(m, `<option value="IN" selected>`); n != 2 { |
| + t.Fatalf("India is selected %d times, want both the quote form and the checkout form", n) |
| + } |
| + // The page's own form names another country: that is what is quoted, |
| + // in the currency still chosen. |
| + _, _, body = get(t, c, fx.u("/billing")+"?country=IE") |
| + m = mainOf(t, body) |
| + for _, want := range []string{`<option value="IE" selected>Ireland</option>`, "Prices for Ireland are 100%", "<strong>£22</strong> a year", `<option value="gbp" selected>GBP</option>`} { |
| + if !strings.Contains(m, want) { |
| + t.Fatalf("billing page quoting Ireland is missing %q in:\n%s", want, m) |
| + } |
| + } |
| + if strings.Contains(m, `<option value="IN" selected>`) { |
| + t.Fatal("India is still selected after the form named Ireland") |
| + } |
| + // And the form's country replaced the link's: a plain GET quotes it. |
| + _, _, body = get(t, c, fx.u("/billing")) |
| + if m = mainOf(t, body); !strings.Contains(m, `<option value="IE" selected>Ireland</option>`) || strings.Contains(m, `<option value="IN" selected>`) { |
| + t.Fatalf("the form's country did not replace the link's:\n%s", m) |
| + } |
| +} |
| + |
| +// A valid ?country= on a GET is kept in a short-lived cookie beside the |
| +// currency's. It is a suggestion: a value that is not a country priced, |
| +// or not spelled as the pricing page spells it, is dropped, never stored, |
| +// and a cookie somebody forged is not believed. A console with billing |
| +// off prices no country and remembers none. |
| +func TestCountrySuggestionCookie(t *testing.T) { |
| + fx, _, _ := newCurrencyFixture(t) |
| + cookie := func(srv *Server, method, target string) *http.Cookie { |
| + w := httptest.NewRecorder() |
| + srv.ServeHTTP(w, httptest.NewRequest(method, target, nil)) |
| + for _, c := range w.Result().Cookies() { |
| + if c.Name == countryCookie { |
| + return c |
| + } |
| + } |
| + return nil |
| + } |
| + for _, target := range []string{"/?country=IN", "/auth/signin?country=IN¤cy=gbp", fx.path("/billing") + "?country=IN&x=1"} { |
| + c := cookie(fx.srv, http.MethodGet, target) |
| + if c == nil || c.Value != "IN" || 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{"/", "/?country=", "/?country=in", "/?country=XX", "/?country=IND", "/?country=IN&country=IE", "/?country=IN%20", "/?currency=gbp"} { |
| + if c := cookie(fx.srv, http.MethodGet, target); c != nil { |
| + t.Errorf("GET %s set %+v", target, c) |
| + } |
| + } |
| + if c := cookie(fx.srv, http.MethodPost, "/?country=IN"); 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: countryCookie, Value: value}) |
| + } |
| + return srv.suggestedCountry(r) |
| + } |
| + for value, want := range map[string]string{"IN": "IN", "IE": "IE", "GB": "GB", "in": "", "XX": "", "": "", "-": "", "IN; Path=/": ""} { |
| + if got := suggest(fx.srv, value); got != want { |
| + t.Errorf("cookie %q suggests %q, want %q", value, got, want) |
| + } |
| + } |
| + off := newConsoleFixture(t) |
| + if c := cookie(off.srv, http.MethodGet, "/?country=IN"); c != nil { |
| + t.Errorf("a console with billing off set %+v", c) |
| + } |
| + if got := suggest(off.srv, "IN"); got != "" { |
| + t.Errorf("a console with billing off suggests %q", got) |
| + } |
| +} |
diff --git a/internal/console/billingpage.go b/internal/console/billingpage.go| index 672f41e13..a8b2e12b8 100644 |
| --- a/internal/console/billingpage.go |
| +++ b/internal/console/billingpage.go |
| @@ -74,7 +74,8 @@ type billingPageView struct { |
| Currencies []billingCurrencyOption |
| Currency string |
| CurrencyChosen bool |
| - // Quote is the ?country=XX preview. QuoteError is a ?country the page |
| + // Quote is the ?country=XX preview, or the country the visitor chose on |
| + // the pricing page when the form has not named one. QuoteError is a ?country the page |
| // could not price (malformed or not a known band). |
| Quote *billingQuote |
| QuoteError bool |
| @@ -189,7 +190,11 @@ func (s *Server) handleBillingPage(w http.ResponseWriter, r *http.Request) { |
| v.Countries = s.billingCountries() |
| band := plans.BandA |
| country := "" |
| - if q := r.URL.Query().Get("country"); q != "" { |
| + q := r.URL.Query().Get("country") |
| + if q == "" { |
| + q = s.suggestedCountry(r) |
| + } |
| + if q != "" { |
| iso := strings.ToUpper(strings.TrimSpace(q)) |
| var hit *billingCountry |
| if len(iso) == 2 { |