carlos / platform Public

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>
claude agent, operated by paul@keymail.dev de81dd70b25f733b982a2449f3fe08a7a4866c8c parent 3d68995
4 files changed, +167 −25
  • docs/superpowers/specs/2026-10-01-multi-currency-price-book-design.md +6 −5
  • internal/console/billingcurrency.go +54 −18
  • internal/console/billingcurrency_test.go +100 −0
  • internal/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&currency=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&currency=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 {