The billing page offers the currency in the approved words and prices in it
On a console that sells from a price book, the price preview form gains a currency select beside the billing country, with the five approved currency strings; the plans are priced in that currency (the buyer's ?currency= when sold, else the pricing page's suggestion from the cookie, else the quoted country's default, else the book's); and the checkout form posts the currency the page shows. The layout session's redesign (grouped radio cards, the inclusions table) is merged in unchanged. A console with no book shows the page it always did.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 files changed,
+248
−16
internal/console/billingcurrency_test.go+150 −0internal/console/billingpage.go+85 −15internal/console/billingpage_test.go+1 −1internal/console/templates/billing.html+12 −0
CI passed — run details
diff --git a/internal/console/billingcurrency_test.go b/internal/console/billingcurrency_test.go| index 601c314a7..4847ac16c 100644 |
| --- a/internal/console/billingcurrency_test.go |
| +++ b/internal/console/billingcurrency_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "encoding/hex" |
| "encoding/json" |
| "errors" |
| + "io" |
| "net/http" |
| "net/http/httptest" |
| "net/url" |
| @@ -1567,3 +1568,152 @@ func TestInvoiceRecoveryChecksTheCurrencyOfWhatItFinds(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// The billing page on a console that sells from a price book: the currency |
| +// controls in the approved words, prices in the page's currency, and the |
| +// checkout form posting the currency the page shows. The page's currency is |
| +// the buyer's ?currency= when it is one sold, else the pricing page's |
| +// suggestion in the cookie, else the quoted country's default, else the |
| +// book's. A console with no book shows no controls and the same page as |
| +// before. |
| +func TestBillingPageCurrencyControls(t *testing.T) { |
| + fx, _, _ := newCurrencyFixture(t) |
| + // A fresh sign-in for each request: a ?currency= on a GET sets the |
| + // suggestion cookie, and a client's jar would carry it into the next |
| + // case. |
| + fetch := func(t *testing.T, query string, cookie string) string { |
| + t.Helper() |
| + c := signInPaul(t, fx) |
| + req, _ := http.NewRequest(http.MethodGet, fx.u("/billing")+query, nil) |
| + if cookie != "" { |
| + req.AddCookie(&http.Cookie{Name: currencyCookie, Value: cookie}) |
| + } |
| + res, err := c.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer res.Body.Close() |
| + body, _ := io.ReadAll(res.Body) |
| + if res.StatusCode != http.StatusOK { |
| + t.Fatalf("GET /billing%s = %d:\n%s", query, res.StatusCode, body) |
| + } |
| + return mainOf(t, string(body)) |
| + } |
| + m := fetch(t, "", "") |
| + // The five approved strings, verbatim. |
| + for _, want := range []string{ |
| + "Currency", "Billing country", |
| + "Choose USD, EUR or GBP for your subscription and usage bills.", |
| + "Your billing country sets your pricing band. Changing currency does not change your inclusions.", |
| + "Your selected currency stays with your subscription and usage bills.", |
| + `<select name="currency">`, `<option value="usd" selected>USD</option>`, `<option value="eur">EUR</option>`, `<option value="gbp">GBP</option>`, |
| + `<input type="hidden" name="currency" value="usd">`, |
| + "<strong>$28</strong> a year", "<strong>$6</strong> per member a year", "<strong>$60</strong> per member a year", |
| + } { |
| + if !strings.Contains(m, want) { |
| + t.Fatalf("billing page with no choice made is missing %q in:\n%s", want, m) |
| + } |
| + } |
| + // The posted field is inside the checkout form, not the GET form. |
| + getForm, _, _ := strings.Cut(m[strings.Index(m, `method="get"`):], "</form>") |
| + if strings.Contains(getForm, `type="hidden" name="currency"`) { |
| + t.Fatal("the hidden currency is in the price preview form") |
| + } |
| + postForm, _, _ := strings.Cut(m[strings.Index(m, `method="post" action="/a/`+fx.sqid+`/billing/checkout"`):], "</form>") |
| + if !strings.Contains(postForm, `<input type="hidden" name="currency" value="usd">`) { |
| + t.Fatal("the checkout form does not post the page's currency") |
| + } |
| + for name, tc := range map[string]struct { |
| + query, cookie, want, price string |
| + band string |
| + }{ |
| + "the quoted country's default": {"?country=IE", "", "eur", "<strong>€24</strong> a year", "100%"}, |
| + "the United Kingdom's default": {"?country=GB", "", "gbp", "<strong>£22</strong> a year", "100%"}, |
| + "the buyer's choice over the country": {"?country=IE¤cy=gbp", "", "gbp", "<strong>£22</strong> a year", "100%"}, |
| + "the pricing page's suggestion": {"", "gbp", "gbp", "<strong>£22</strong> a year", ""}, |
| + "the buyer's choice over the suggestion": {"?currency=eur", "gbp", "eur", "<strong>€24</strong> a year", ""}, |
| + "the country's default over no suggestion": {"?country=FR", "", "eur", "<strong>€24</strong> a year", "100%"}, |
| + "a band C country in its default": {"?country=PL", "", "eur", "<strong>€12</strong> a year", "50%"}, |
| + "a band D country in pounds": {"?country=IN¤cy=gbp", "", "gbp", "<strong>£7.70</strong> a year", "35%"}, |
| + "a currency not sold falls to the default": {"?currency=jpy", "", "usd", "<strong>$28</strong> a year", ""}, |
| + "a forged cookie falls to the default": {"", "jpy", "usd", "<strong>$28</strong> a year", ""}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + m := fetch(t, tc.query, tc.cookie) |
| + for _, want := range []string{`<option value="` + tc.want + `" selected>`, `<input type="hidden" name="currency" value="` + tc.want + `">`, tc.price} { |
| + if !strings.Contains(m, want) { |
| + t.Fatalf("missing %q in:\n%s", want, m) |
| + } |
| + } |
| + if tc.band != "" && !strings.Contains(m, "are "+tc.band+" of the base price") { |
| + t.Fatalf("the band line does not say %s", tc.band) |
| + } |
| + // The country is selected in both country selects (the preview |
| + // form's and the checkout form's), the currency in one. |
| + if strings.Count(m, " selected>") > 3 { |
| + t.Fatalf("more than one currency or country selected:\n%s", m) |
| + } |
| + }) |
| + } |
| + // Band D in pounds, per member: the rounding rule's figures. |
| + m = fetch(t, "?country=IN¤cy=gbp", "") |
| + for _, want := range []string{"<strong>£1.58</strong> per member a year", "£15.80 a year", "<strong>£15.75</strong> per member a year"} { |
| + if !strings.Contains(m, want) { |
| + t.Fatalf("band D in pounds is missing %q in:\n%s", want, m) |
| + } |
| + } |
| + // A console with no price book: EUR, no controls, the page as it was. |
| + old, _, _ := newBillingFixture(t) |
| + oc := signInPaul(t, old) |
| + _, _, body := get(t, oc, old.u("/billing?currency=gbp")) |
| + om := mainOf(t, body) |
| + for _, absent := range []string{`name="currency"`, "Choose USD, EUR or GBP", "Your selected currency stays"} { |
| + if strings.Contains(om, absent) { |
| + t.Fatalf("a console with no book shows %q", absent) |
| + } |
| + } |
| + if !strings.Contains(om, "<strong>€24</strong> a year") { |
| + t.Fatalf("a console with no book does not price in euro:\n%s", om) |
| + } |
| +} |
| + |
| +// What the page posts is what the door takes: the owner picks a currency on |
| +// the page, the form posts it, and the intent is opened in it. |
| +func TestBillingPagePostsTheCurrencyItShows(t *testing.T) { |
| + fx, f, acct := newCurrencyFixture(t) |
| + const owner = "paul@keymail.dev" |
| + raw, err := fx.st.CreateSession(owner, "", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.RecordStepUp(raw, owner, time.Now(), time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + page := httptest.NewRequest(http.MethodGet, fx.path("/billing")+"?country=IE¤cy=gbp", nil) |
| + page.AddCookie(&http.Cookie{Name: sessionCookie, Value: raw}) |
| + w := httptest.NewRecorder() |
| + fx.srv.ServeHTTP(w, page) |
| + body := w.Body.String() |
| + i := strings.Index(body, `<input type="hidden" name="currency" value="`) |
| + if i < 0 { |
| + t.Fatalf("no currency field:\n%s", body) |
| + } |
| + posted := body[i+len(`<input type="hidden" name="currency" value="`):] |
| + posted = posted[:strings.Index(posted, `"`)] |
| + form := url.Values{"csrf": {raw}, "plan": {"personal"}, "country": {"IE"}, "currency": {posted}, "consent": {plans.CurrentTermsVersion}} |
| + r := httptest.NewRequest(http.MethodPost, fx.path("/billing/checkout"), strings.NewReader(form.Encode())) |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + r.AddCookie(&http.Cookie{Name: sessionCookie, Value: raw}) |
| + w = httptest.NewRecorder() |
| + fx.srv.ServeHTTP(w, r) |
| + if w.Code != http.StatusSeeOther { |
| + t.Fatalf("checkout: %d %s", w.Code, w.Body.String()) |
| + } |
| + intent, err := fx.st.billingCheckoutIntent(acct.ID) |
| + if err != nil || intent.Currency != "gbp" || intent.Band != "A" { |
| + t.Fatalf("intent %+v %v; want gbp at Ireland's band", intent, err) |
| + } |
| + if got := f.forms[len(f.forms)-1].Get("currency"); got != "gbp" { |
| + t.Fatalf("the session was asked for in %q", got) |
| + } |
| +} |
diff --git a/internal/console/billingpage.go b/internal/console/billingpage.go| index dfb5c53ce..672f41e13 100644 |
| --- a/internal/console/billingpage.go |
| +++ b/internal/console/billingpage.go |
| @@ -61,8 +61,19 @@ type billingPageView struct { |
| TermsVersion string |
| // Plans is the chooser's rows, priced for the quoted country or, with |
| - // none, the base band. |
| + // none, the base band, in Currency. |
| Plans []billingPlanRow |
| + // Currencies are the currencies the console sells in, in the price |
| + // book's order, each with whether it is the one the page is priced |
| + // in. With one currency (a console with no price book) no choice is |
| + // offered. Currency is the one the page is priced in and the checkout |
| + // form posts: the ?currency= the page was asked for, else the |
| + // suggestion carried from the pricing page, else the quoted country's |
| + // default, else the book's default. CurrencyChosen says it was the |
| + // buyer's own choice (the query), not a default. |
| + Currencies []billingCurrencyOption |
| + Currency string |
| + CurrencyChosen bool |
| // Quote is the ?country=XX preview. QuoteError is a ?country the page |
| // could not price (malformed or not a known band). |
| Quote *billingQuote |
| @@ -77,12 +88,19 @@ type billingCountry struct { |
| Selected bool |
| } |
| +type billingCurrencyOption struct { |
| + Code string // lower case, as posted |
| + Label string // upper case, as shown |
| + Selected bool |
| +} |
| + |
| type billingPlanRow struct { |
| Plan, Name string |
| Team bool |
| // Price is the yearly price for the whole account at the quantity the |
| // checkout door would charge; PerMember is the per-member yearly price |
| - // for team plans. Both are formatted euro amounts ("€24", "€8.40"). |
| + // for team plans. Both are formatted in the page's currency ("€24", |
| + // "$8.40", "£1.58"). |
| Price, PerMember string |
| // Unavailable: the checkout door would refuse this plan for the |
| // account's member count. The row is shown and cannot be chosen. |
| @@ -170,6 +188,7 @@ func (s *Server) handleBillingPage(w http.ResponseWriter, r *http.Request) { |
| if v.CanChoose { |
| v.Countries = s.billingCountries() |
| band := plans.BandA |
| + country := "" |
| if q := r.URL.Query().Get("country"); q != "" { |
| iso := strings.ToUpper(strings.TrimSpace(q)) |
| var hit *billingCountry |
| @@ -184,19 +203,59 @@ func (s *Server) handleBillingPage(w http.ResponseWriter, r *http.Request) { |
| v.QuoteError = true |
| } else if pct, ok := billingBandPercent(hit.Band); ok { |
| hit.Selected = true |
| - band = hit.Band |
| + band, country = hit.Band, hit.ISO2 |
| v.Quote = &billingQuote{ISO2: hit.ISO2, Country: hit.Name, Percent: pct} |
| } else { |
| v.QuoteError = true |
| } |
| } |
| - v.Plans = billingPlanRows(band, int64(v.Members)) |
| + terms := s.pageCurrency(r, v, country) |
| + v.Plans = billingPlanRows(terms, band, int64(v.Members)) |
| } |
| p.Billing = v |
| s.render(w, http.StatusOK, "billing", p) |
| } |
| +// pageCurrency decides the currency the page is priced in and fills the |
| +// view's currency fields. The query's ?currency= wins when it is one the |
| +// console sells in (it is the buyer's own choice, and the page says so); |
| +// else the suggestion the pricing page's link left in the cookie; else the |
| +// quoted billing country's default; else the book's default. None of this |
| +// is the purchase's currency: that is what the checkout form posts, and |
| +// the form posts what this chose. |
| +func (s *Server) pageCurrency(r *http.Request, v *billingPageView, country string) plans.PriceTerms { |
| + book := plans.LegacyPriceBook |
| + if s.billing != nil { |
| + book = s.billing.cfg.PriceBook |
| + } |
| + chosen := "" |
| + if q := r.URL.Query().Get("currency"); q != "" { |
| + if c, ok := s.soldCurrency(q); ok { |
| + chosen, v.CurrencyChosen = c, true |
| + } |
| + } |
| + if chosen == "" { |
| + chosen = s.suggestedCurrency(r) |
| + } |
| + if chosen == "" { |
| + if c, err := plans.CurrencyForCountry(book, country); err == nil { |
| + chosen = string(c) |
| + } |
| + } |
| + terms := plans.PriceTerms{TermsVersion: plans.CurrentTermsVersion, PriceBook: book, Currency: plans.Currency(chosen)} |
| + if terms.Legacy() { |
| + terms.Currency = "" |
| + } |
| + v.Currency = string(terms.Money()) |
| + if sold := s.billingCurrencies(); len(sold) > 1 { |
| + for _, c := range sold { |
| + v.Currencies = append(v.Currencies, billingCurrencyOption{Code: c, Label: strings.ToUpper(c), Selected: c == v.Currency}) |
| + } |
| + } |
| + return terms |
| +} |
| + |
| // billingReviewOpen reports whether the account has any unresolved billing |
| // review hold. It is the same read freezeBillingMonth and the invoice path |
| // use; the page only reports it. |
| @@ -250,9 +309,10 @@ func billingBandPercent(band string) (int64, bool) { |
| return at.AnnualPriceMicroEUR * 100 / base.AnnualPriceMicroEUR, true |
| } |
| -// billingPlanRows prices each paid plan at band for the quantity the |
| -// checkout door would charge: max(members, the plan's minimum). |
| -func billingPlanRows(band string, members int64) []billingPlanRow { |
| +// billingPlanRows prices each paid plan at band, under terms (the price |
| +// book and currency the page shows), for the quantity the checkout door |
| +// would charge: max(members, the plan's minimum). |
| +func billingPlanRows(terms plans.PriceTerms, band string, members int64) []billingPlanRow { |
| var rows []billingPlanRow |
| for _, plan := range billingPaidPlans { |
| def, err := plans.Lookup(plans.CurrentTermsVersion, plan) |
| @@ -265,16 +325,16 @@ func billingPlanRows(band string, members int64) []billingPlanRow { |
| // price the door would then refuse. |
| qty := max(members, def.MinQuantity) |
| row := billingPlanRow{Plan: plan, Name: billingPlanNames[plan], Team: def.MinQuantity > 1} |
| - q, err := plans.Quote(plans.CurrentTermsVersion, plan, band, qty) |
| + price, err := plans.PriceFor(terms, plan, band, qty) |
| if err != nil { |
| row.Unavailable = true |
| - if q, err = plans.Quote(plans.CurrentTermsVersion, plan, band, def.MinQuantity); err != nil { |
| + if price, err = plans.PriceFor(terms, plan, band, def.MinQuantity); err != nil { |
| continue |
| } |
| } |
| - row.Price = formatEuroMicro(q.AnnualPriceMicroEUR) |
| + row.Price = formatMinor(terms.Money(), price.AnnualMinor) |
| if row.Team { |
| - row.PerMember = formatEuroMicro(q.AnnualPriceMicroEUR / q.Quantity) |
| + row.PerMember = formatMinor(terms.Money(), price.UnitMinor) |
| } |
| rows = append(rows, row) |
| } |
| @@ -285,14 +345,24 @@ func billingPlanRows(band string, members int64) []billingPlanRow { |
| // "€8.40": no trailing ".00". Sub-cent remainders are dropped; plan prices |
| // are whole cents by construction. |
| func formatEuroMicro(micro int64) string { |
| - cents := micro / 10000 |
| - euros, rem := cents/100, cents%100 |
| + return formatMinor(plans.EUR, micro/10000) |
| +} |
| + |
| +// formatMinor renders an amount in a currency's minor units as the pricing |
| +// page does: the symbol, the major amount, and the minor part only when it |
| +// is not zero ("€24", "$8.40", "£1.58"). |
| +func formatMinor(currency plans.Currency, minor int64) string { |
| + symbol := map[plans.Currency]string{plans.USD: "$", plans.EUR: "€", plans.GBP: "£"}[currency] |
| + if symbol == "" { |
| + symbol = strings.ToUpper(string(currency)) + " " |
| + } |
| + major, rem := minor/100, minor%100 |
| if rem == 0 { |
| - return "€" + strconv.FormatInt(euros, 10) |
| + return symbol + strconv.FormatInt(major, 10) |
| } |
| r := strconv.FormatInt(rem, 10) |
| if rem < 10 { |
| r = "0" + r |
| } |
| - return "€" + strconv.FormatInt(euros, 10) + "." + r |
| + return symbol + strconv.FormatInt(major, 10) + "." + r |
| } |
diff --git a/internal/console/billingpage_test.go b/internal/console/billingpage_test.go| index 471cb1228..3ae2e5996 100644 |
| --- a/internal/console/billingpage_test.go |
| +++ b/internal/console/billingpage_test.go |
| @@ -302,7 +302,7 @@ func TestBillingPagePriceFormatting(t *testing.T) { |
| t.Errorf("formatEuroMicro(%d) = %q, want %q", micro, got, want) |
| } |
| } |
| - rows := billingPlanRows(plans.BandD, 3) |
| + rows := billingPlanRows(plans.LegacyPriceTerms(plans.CurrentTermsVersion), plans.BandD, 3) |
| if len(rows) != 3 { |
| t.Fatalf("rows = %+v", rows) |
| } |
diff --git a/internal/console/templates/billing.html b/internal/console/templates/billing.html| index 4fcc8142e..f6abfadd4 100644 |
| --- a/internal/console/templates/billing.html |
| +++ b/internal/console/templates/billing.html |
| @@ -49,13 +49,24 @@ |
| {{end}} |
| </select> |
| </label> |
| + {{if .Currencies}}<label><!-- currency.label (currency review) -->Currency |
| + <select name="currency"> |
| + {{range .Currencies}}<option value="{{.Code}}"{{if .Selected}} selected{{end}}>{{.Label}}</option> |
| + {{end}} |
| + </select> |
| + </label>{{end}} |
| <button class="linkish" type="submit"><!-- billing.quote.button -->Show prices for this country</button> |
| </form> |
| +{{if .Currencies}} |
| +<p class="small"><!-- currency.explanation (currency review) -->Choose USD, EUR or GBP for your subscription and usage bills.</p> |
| +<p class="small"><!-- country.explanation (currency review) -->Your billing country sets your pricing band. Changing currency does not change your inclusions.</p> |
| +{{end}} |
| {{if .QuoteError}}<p class="small"><!-- billing.quote.unknown (not in strings.json) -->There are no prices for that country. Choose one from the list.</p>{{end}} |
| {{with .Quote}}<p class="small"><!-- billing.quote.band -->Prices for {{.Country}} are {{.Percent}}% of the base price, and so are the monthly inclusions.</p>{{end}} |
| <form class="billing-checkout" method="post" action="{{$.Base}}/billing/checkout"> |
| <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + {{if .Currencies}}<input type="hidden" name="currency" value="{{.Currency}}">{{end}} |
| <fieldset class="billing-plan-options" aria-labelledby="billing-choose-heading"> |
| {{range .Plans}} |
| <label class="billing-plan-option{{if .Unavailable}} unavailable{{end}}"> |
| @@ -98,6 +109,7 @@ |
| <!-- billing.consent.label, with billing.consent.link inside -->I have read <a href="https://carloku.com/pricing/">the pricing page</a> and agree to be charged yearly at the price shown at checkout, plus usage beyond the plan's inclusions billed monthly.</label></p> |
| <button type="submit"><!-- billing.pay.button -->Continue to payment</button> |
| <p class="small"><!-- billing.pay.note -->You pay by card at Stripe. Nothing is charged until you confirm there.</p> |
| + {{if .Currencies}}<p class="small"><!-- currency.fixed (currency review) -->Your selected currency stays with your subscription and usage bills.</p>{{end}} |
| </div> |
| </form> |
| {{end}} |