carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 3d6899523cafb1cc7701842545f3356741f8189a parent 5047494
4 files changed, +248 −16
  • internal/console/billingcurrency_test.go +150 −0
  • internal/console/billingpage.go +85 −15
  • internal/console/billingpage_test.go +1 −1
  • internal/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&currency=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&currency=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&currency=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&currency=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}}