Add the console GET billing page
GET /a/{sqid}/billing renders the account's plan, and for owners the plan
chooser (plan, billing country, postal code, terms consent) posting to the
existing checkout door, an optional ?country=XX price preview computed from
the plans terms, the ?error= and ?pending=1 banners, and the portal button
once a Stripe customer exists. Members see a read-only note; a non-member
gets the same 404 as every account page; a deployment with billing off
shows the Free plan and no forms. plans.Countries() exposes the embedded
country list for the picker.
Verified: go build, go vet, gofmt, internal/plans and the BillingPage
tests pass. Not verified: live Stripe (none touched); every user-facing
string is draft copy under review (implementation/carloku-paid-launch/
copy-review/billing-page/strings.json) and carries its copy id in an HTML
comment so the approved text can be applied byte for byte.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
7 files changed,
+797
−1
internal/console/billingpage.go+290 −0internal/console/billingpage_test.go+343 −0internal/console/server.go+5 −1internal/console/templates/billing.html+98 −0internal/console/templates/layout.html+1 −0internal/plans/countries.go+25 −0internal/plans/countries_test.go+35 −0
diff --git a/internal/console/billingpage.go b/internal/console/billingpage.go| new file mode 100644 |
| index 000000000..9beeef99a |
| --- /dev/null |
| +++ b/internal/console/billingpage.go |
| @@ -0,0 +1,290 @@ |
| +package console |
| + |
| +import ( |
| + "log" |
| + "net/http" |
| + "sort" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| +) |
| + |
| +// billingPlanNames are the display names the billing page, Stripe's checkout |
| +// page and invoices use (copy ids billing.plan.<plan>.name; "Free" is the |
| +// free plan's name in billing.current.free). |
| +var billingPlanNames = map[string]string{ |
| + plans.PlanFree: "Free", |
| + plans.PlanPersonal: "Personal", |
| + plans.PlanCommunity: "Community", |
| + plans.PlanBusiness: "Business", |
| +} |
| + |
| +// billingPaidPlans is the chooser's order. |
| +var billingPaidPlans = []string{plans.PlanPersonal, plans.PlanCommunity, plans.PlanBusiness} |
| + |
| +// billingPageView is everything templates/billing.html reads. It is read |
| +// only: the page never writes, and every mutation it offers posts to the |
| +// existing checkout and portal doors (billinghandlers.go), which run the |
| +// full session, CSRF, owner and fresh-auth gate themselves. |
| +type billingPageView struct { |
| + // Closed is true when billing is switched off on this deployment |
| + // (s.billing == nil). The page then shows the Free plan and no forms. |
| + Closed bool |
| + |
| + Plan, PlanName string |
| + Quantity int64 |
| + Renews string // PeriodEnd as 2006-01-02, UTC |
| + Status string // the raw subscription status |
| + // Active is a running, paid-up (or trialing) subscription; PastDue is a |
| + // running one whose last payment failed (past_due or unpaid). Either |
| + // renders the paid plan line; neither renders the Free line. |
| + Active, PastDue bool |
| + // Review is an open (unresolved) billing review hold on the account. |
| + Review bool |
| + // PendingCheckout is the ?pending=1 return from Stripe's success URL, |
| + // for an account whose paid state has not yet arrived. |
| + PendingCheckout bool |
| + // Customer reports whether the account has a Stripe customer, which is |
| + // what the portal door needs. The id itself is never rendered. |
| + Customer bool |
| + |
| + Owner bool |
| + // CanChoose is an owner on an open deployment whose account holds no |
| + // subscription the checkout door would refuse to replace. |
| + CanChoose bool |
| + Members int |
| + MinQuantity int64 |
| + |
| + Countries []billingCountry |
| + TermsVersion string |
| + |
| + // Plans is the chooser's rows, priced for the quoted country or, with |
| + // none, the base band. |
| + Plans []billingPlanRow |
| + // Quote is the ?country=XX preview. QuoteError is a ?country the page |
| + // could not price (malformed or not a known band). |
| + Quote *billingQuote |
| + QuoteError bool |
| + |
| + // Error is the ?error= key (unavailable|pending|conflict), "" otherwise. |
| + Error string |
| +} |
| + |
| +type billingCountry struct { |
| + ISO2, Name, Band string |
| + 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"). |
| + Price, PerMember string |
| +} |
| + |
| +type billingQuote struct { |
| + ISO2, Country string |
| + Percent int64 |
| +} |
| + |
| +// handleBillingPage is GET /a/{sqid}/billing: the account's plan, and for |
| +// owners the chooser and the portal button. Gate is s.account exactly like |
| +// the other account pages (session, then membership; a non-member gets the |
| +// same 404). There is no requireFreshAuth on the read: the doors it links to |
| +// step up themselves. |
| +func (s *Server) handleBillingPage(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, role, ok := s.account(w, r) |
| + if !ok { |
| + return |
| + } |
| + p := s.accountPage(w, r, addr, raw, acct, role) |
| + p.Title = "Billing" // billing.title |
| + p.ActiveNav = "billing" |
| + |
| + v := &billingPageView{ |
| + Closed: s.billing == nil, |
| + Plan: plans.PlanFree, |
| + Owner: role == RoleOwner, |
| + TermsVersion: plans.CurrentTermsVersion, |
| + MinQuantity: 10, |
| + } |
| + if def, err := plans.Lookup(plans.CurrentTermsVersion, plans.PlanCommunity); err == nil { |
| + v.MinQuantity = def.MinQuantity |
| + } |
| + switch e := r.URL.Query().Get("error"); e { |
| + case "unavailable", "pending", "conflict": |
| + v.Error = e |
| + } |
| + |
| + // Billing switched off: every account is on Free, whatever an old row |
| + // might say, and the page offers nothing to press. |
| + if !v.Closed { |
| + state, err := s.store.billingAccount(acct.ID) |
| + if err != nil { |
| + log.Printf("console: billing page state: %v", err) |
| + s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read billing. Please try again.") |
| + return |
| + } |
| + v.Status = state.Status |
| + v.Customer = state.Customer != "" |
| + switch state.Status { |
| + case "active", "trialing": |
| + v.Active = true |
| + case "past_due", "unpaid": |
| + v.PastDue = true |
| + } |
| + if (v.Active || v.PastDue) && billingPlanNames[state.Plan] != "" { |
| + v.Plan, v.Quantity = state.Plan, state.Quantity |
| + if state.PeriodEnd > 0 { |
| + v.Renews = time.Unix(state.PeriodEnd, 0).UTC().Format("2006-01-02") |
| + } |
| + } else { |
| + v.Active, v.PastDue = false, false |
| + } |
| + // The checkout door refuses any subscription that is not over; the |
| + // chooser is not offered where it can only fail. |
| + replaceable := state.Subscription == "" || state.Status == "canceled" || state.Status == "incomplete_expired" |
| + v.CanChoose = v.Owner && replaceable |
| + v.PendingCheckout = r.URL.Query().Get("pending") == "1" && !v.Active && !v.PastDue |
| + if open, err := s.billingReviewOpen(acct.ID); err != nil { |
| + log.Printf("console: billing page review: %v", err) |
| + } else { |
| + v.Review = open |
| + } |
| + } |
| + v.PlanName = billingPlanNames[v.Plan] |
| + |
| + if members, err := s.store.Members(acct.ID); err != nil { |
| + log.Printf("console: billing page members: %v", err) |
| + } else { |
| + v.Members = len(members) |
| + } |
| + |
| + if v.CanChoose { |
| + v.Countries = s.billingCountries() |
| + band := plans.BandA |
| + if q := r.URL.Query().Get("country"); q != "" { |
| + iso := strings.ToUpper(strings.TrimSpace(q)) |
| + var hit *billingCountry |
| + if len(iso) == 2 { |
| + for i := range v.Countries { |
| + if v.Countries[i].ISO2 == iso { |
| + hit = &v.Countries[i] |
| + } |
| + } |
| + } |
| + if hit == nil { |
| + v.QuoteError = true |
| + } else if pct, ok := billingBandPercent(hit.Band); ok { |
| + hit.Selected = true |
| + band = hit.Band |
| + v.Quote = &billingQuote{ISO2: hit.ISO2, Country: hit.Name, Percent: pct} |
| + } else { |
| + v.QuoteError = true |
| + } |
| + } |
| + v.Plans = billingPlanRows(band, int64(v.Members)) |
| + } |
| + |
| + p.Billing = v |
| + s.render(w, http.StatusOK, "billing", p) |
| +} |
| + |
| +// 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. |
| +func (s *Server) billingReviewOpen(accountID int64) (bool, error) { |
| + var open bool |
| + err := s.store.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM billing_reviews WHERE account_id=? AND resolved_at=0)`, accountID).Scan(&open) |
| + return open, err |
| +} |
| + |
| +// billingCountries is the chooser's country list: the deployment's |
| +// configured bands when billing is on, the embedded terms otherwise, joined |
| +// with the embedded names and sorted by name. |
| +func (s *Server) billingCountries() []billingCountry { |
| + names := make(map[string]string) |
| + for _, c := range plans.Countries() { |
| + names[c.ISO2] = c.Name |
| + } |
| + bands := plans.CountryBands() |
| + if s.billing != nil { |
| + bands = s.billing.cfg.CountryBands |
| + } |
| + out := make([]billingCountry, 0, len(bands)) |
| + for iso, band := range bands { |
| + name := names[iso] |
| + if name == "" { |
| + name = iso |
| + } |
| + out = append(out, billingCountry{ISO2: iso, Name: name, Band: band}) |
| + } |
| + sort.Slice(out, func(i, j int) bool { |
| + if out[i].Name != out[j].Name { |
| + return out[i].Name < out[j].Name |
| + } |
| + return out[i].ISO2 < out[j].ISO2 |
| + }) |
| + return out |
| +} |
| + |
| +// billingBandPercent derives a band's percentage of the base price from the |
| +// terms themselves (Personal at the band against Personal at band A), so the |
| +// page can never state a factor the checkout would not charge. |
| +func billingBandPercent(band string) (int64, bool) { |
| + at, err := plans.Quote(plans.CurrentTermsVersion, plans.PlanPersonal, band, 1) |
| + if err != nil { |
| + return 0, false |
| + } |
| + base, err := plans.Quote(plans.CurrentTermsVersion, plans.PlanPersonal, plans.BandA, 1) |
| + if err != nil || base.AnnualPriceMicroEUR <= 0 { |
| + return 0, false |
| + } |
| + 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 { |
| + var rows []billingPlanRow |
| + for _, plan := range billingPaidPlans { |
| + def, err := plans.Lookup(plans.CurrentTermsVersion, plan) |
| + if err != nil { |
| + continue |
| + } |
| + qty := max(members, def.MinQuantity) |
| + if qty > def.MaxQuantity { |
| + qty = def.MaxQuantity |
| + } |
| + q, err := plans.Quote(plans.CurrentTermsVersion, plan, band, qty) |
| + if err != nil { |
| + continue |
| + } |
| + row := billingPlanRow{Plan: plan, Name: billingPlanNames[plan], Team: def.MinQuantity > 1, Price: formatEuroMicro(q.AnnualPriceMicroEUR)} |
| + if row.Team { |
| + row.PerMember = formatEuroMicro(q.AnnualPriceMicroEUR / q.Quantity) |
| + } |
| + rows = append(rows, row) |
| + } |
| + return rows |
| +} |
| + |
| +// formatEuroMicro renders a microEUR amount in whole cents as "€24" or |
| +// "€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 |
| + if rem == 0 { |
| + return "€" + strconv.FormatInt(euros, 10) |
| + } |
| + r := strconv.FormatInt(rem, 10) |
| + if rem < 10 { |
| + r = "0" + r |
| + } |
| + return "€" + strconv.FormatInt(euros, 10) + "." + r |
| +} |
diff --git a/internal/console/billingpage_test.go b/internal/console/billingpage_test.go| new file mode 100644 |
| index 000000000..564df819b |
| --- /dev/null |
| +++ b/internal/console/billingpage_test.go |
| @@ -0,0 +1,343 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| +) |
| + |
| +// mainOf returns the page body between <main> and </main>, so a "no forms" |
| +// assertion is not tripped by the header's sign-out form. |
| +func mainOf(t *testing.T, body string) string { |
| + t.Helper() |
| + _, rest, ok := strings.Cut(body, "<main>") |
| + if !ok { |
| + t.Fatalf("no <main> in:\n%s", body) |
| + } |
| + m, _, ok := strings.Cut(rest, "</main>") |
| + if !ok { |
| + t.Fatalf("unterminated <main> in:\n%s", body) |
| + } |
| + return m |
| +} |
| + |
| +const ( |
| + billingClosedCopy = "Paid plans are not open on this console yet. Every account is on the Free plan." |
| + billingReadonlyCopy = "Only an account owner can change the plan." |
| + billingFreeCopy = "Free. One member, 3 apps, 100 awake hours a month, 250 MB of storage and 1 GB of transfer. Apps sleep after 2 minutes idle." |
| + billingPendingCopy = "Thanks. Stripe is confirming your payment. Your plan turns on within a few minutes, and this page will show it." |
| + billingErrPending = "An earlier checkout for this account is still completing. Wait a minute and reload this page before trying again." |
| +) |
| + |
| +// 1. An owner on an open deployment gets the page, the active nav link, and |
| +// the chooser posting to the checkout door with the consent checkbox the |
| +// door checks. No customer means no portal form. |
| +func TestBillingPageOwnerSeesChooser(t *testing.T) { |
| + fx, _, _ := newBillingFixture(t) |
| + c := signInPaul(t, fx) |
| + status, _, body := get(t, c, fx.u("/billing")) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET /billing = %d:\n%s", status, body) |
| + } |
| + if !strings.Contains(body, "<title>Billing") { |
| + t.Fatalf("title is not Billing:\n%s", body) |
| + } |
| + h := header(t, body) |
| + if !strings.Contains(h, `/billing" aria-current="page">Billing</a>`) { |
| + t.Fatalf("nav does not mark Billing active:\n%s", h) |
| + } |
| + if strings.Count(h, `aria-current="page"`) != 1 { |
| + t.Fatalf("want exactly one active nav section, got:\n%s", h) |
| + } |
| + m := mainOf(t, body) |
| + for _, want := range []string{ |
| + `method="post" action="/a/` + fx.sqid + `/billing/checkout"`, |
| + `name="consent" value="` + plans.CurrentTermsVersion + `"`, |
| + `name="plan" value="personal"`, `name="plan" value="community"`, `name="plan" value="business"`, |
| + `name="postal"`, `<option value="">Choose a country</option>`, `<option value="IE">Ireland</option>`, |
| + `method="get" action="/a/` + fx.sqid + `/billing"`, |
| + `href="https://carloku.com/pricing/"`, |
| + billingFreeCopy, |
| + "<strong>€24</strong> a year", // Personal at the base band |
| + "<strong>€5</strong> per member a year", |
| + "<strong>€50</strong> per member a year", |
| + "You have 1 members; the minimum is 10.", |
| + } { |
| + if !strings.Contains(m, want) { |
| + t.Fatalf("billing page missing %q in:\n%s", want, m) |
| + } |
| + } |
| + if strings.Contains(m, "/billing/portal") { |
| + t.Fatal("portal form offered to an account with no Stripe customer") |
| + } |
| + if strings.Contains(m, "Prices for ") { |
| + t.Fatal("band line shown without a ?country") |
| + } |
| + // The GET price form must never post to checkout: it is its own form. |
| + getForm, _, _ := strings.Cut(m[strings.Index(m, `method="get"`):], "</form>") |
| + if strings.Contains(getForm, "checkout") || strings.Contains(getForm, "csrf") { |
| + t.Fatalf("price preview form leaks into checkout:\n%s", getForm) |
| + } |
| +} |
| + |
| +// 2. A member who is not an owner reads the plan and the read-only note, and |
| +// is offered no form that posts to checkout or the portal. |
| +func TestBillingPageMemberIsReadOnly(t *testing.T) { |
| + fx, _, acct := newBillingFixture(t) |
| + if err := fx.st.AddMember(acct.ID, "kim@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + member, _ := signInAs(t, fx, "kim@keymail.dev", "code-billing-member") |
| + status, _, body := get(t, member, fx.u("/billing")) |
| + if status != http.StatusOK { |
| + t.Fatalf("member GET /billing = %d", status) |
| + } |
| + m := mainOf(t, body) |
| + if !strings.Contains(m, billingReadonlyCopy) || !strings.Contains(m, billingFreeCopy) { |
| + t.Fatalf("member page missing read-only note or plan line:\n%s", m) |
| + } |
| + if strings.Contains(m, "<form") || strings.Contains(m, "/billing/checkout") || strings.Contains(m, "/billing/portal") { |
| + t.Fatalf("member offered a billing form:\n%s", m) |
| + } |
| +} |
| + |
| +// 3. A non-member gets exactly the bytes every other account page answers a |
| +// non-member with: nothing confirms the account exists. |
| +func TestBillingPageNonMemberGetsTheSame404(t *testing.T) { |
| + fx, _, _ := newBillingFixture(t) |
| + stranger, _ := signInAs(t, fx, "eve@keymail.dev", "code-billing-stranger") |
| + bs, bl, billing := get(t, stranger, fx.u("/billing")) |
| + ss, sl, settings := get(t, stranger, fx.u("/settings")) |
| + if bs != http.StatusNotFound || ss != http.StatusNotFound { |
| + t.Fatalf("non-member: billing %d, settings %d, want 404 both", bs, ss) |
| + } |
| + if billing != settings || bl != sl { |
| + t.Fatalf("non-member 404 differs:\nbilling %q %q\nsettings %q %q", bl, billing, sl, settings) |
| + } |
| + // Signed out: the same redirect every account page gives, for any segment. |
| + anon := newBrowser(t) |
| + if st, loc, _ := get(t, anon, fx.u("/billing")); st != http.StatusFound || loc != "/" { |
| + t.Fatalf("signed out = %d -> %q, want 302 -> /", st, loc) |
| + } |
| +} |
| + |
| +// 4. Billing switched off (production today): the Free plan and the closed |
| +// sentence, and no form of any kind in the page body. |
| +func TestBillingPageClosedWhenUnconfigured(t *testing.T) { |
| + fx, _, acct := newBillingFixture(t) |
| + // A stale row must not leak through a closed page. |
| + if _, err := fx.st.db.Exec(`INSERT INTO billing_accounts(account_id,customer,subscription,status,plan,quantity,period_end) VALUES(?,?,?,?,?,?,?)`, |
| + acct.ID, "cus_stale", "sub_stale", "active", "business", 12, time.Now().AddDate(1, 0, 0).Unix()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.billing = nil |
| + c := signInPaul(t, fx) |
| + status, _, body := get(t, c, fx.u("/billing")) |
| + if status != http.StatusOK { |
| + t.Fatalf("closed GET /billing = %d", status) |
| + } |
| + m := mainOf(t, body) |
| + if !strings.Contains(m, billingClosedCopy) || !strings.Contains(m, billingFreeCopy) { |
| + t.Fatalf("closed page missing closed sentence or Free line:\n%s", m) |
| + } |
| + if strings.Contains(m, "<form") || strings.Contains(m, "Business,") { |
| + t.Fatalf("closed page offers a form or a stale plan:\n%s", m) |
| + } |
| +} |
| + |
| +// 5. The price preview, and the banners the POST doors redirect back with. |
| +func TestBillingPageQuoteAndBanners(t *testing.T) { |
| + fx, _, _ := newBillingFixture(t) |
| + c := signInPaul(t, fx) |
| + |
| + status, _, body := get(t, c, fx.u("/billing?country=IE")) |
| + if status != http.StatusOK { |
| + t.Fatalf("?country=IE = %d", status) |
| + } |
| + m := mainOf(t, body) |
| + for _, want := range []string{ |
| + "<strong>€24</strong> a year", |
| + "Prices for Ireland are 100% of the base price, and so are the monthly inclusions.", |
| + `<option value="IE" selected>Ireland</option>`, |
| + } { |
| + if !strings.Contains(m, want) { |
| + t.Fatalf("IE quote missing %q in:\n%s", want, m) |
| + } |
| + } |
| + |
| + for _, bad := range []string{"ZZ", "ireland", "I", "IEE", "<script>"} { |
| + status, _, body = get(t, c, fx.u("/billing?country="+strings.ReplaceAll(bad, "<", "%3C"))) |
| + if status != http.StatusOK { |
| + t.Fatalf("?country=%s = %d", bad, status) |
| + } |
| + m = mainOf(t, body) |
| + if strings.Contains(m, "Prices for ") || strings.Contains(m, " selected>") { |
| + t.Fatalf("?country=%s quoted anyway:\n%s", bad, m) |
| + } |
| + if !strings.Contains(m, "There are no prices for that country.") { |
| + t.Fatalf("?country=%s shows no error line", bad) |
| + } |
| + if strings.Contains(m, "<script>") { |
| + t.Fatal("country echoed unescaped") |
| + } |
| + } |
| + |
| + _, _, body = get(t, c, fx.u("/billing?error=pending")) |
| + if !strings.Contains(body, `<p class="banner error">`+billingErrPending) { |
| + t.Fatalf("?error=pending banner missing:\n%s", mainOf(t, body)) |
| + } |
| + _, _, body = get(t, c, fx.u("/billing?error=bogus")) |
| + if strings.Contains(mainOf(t, body), "banner error") { |
| + t.Fatal("unknown ?error= rendered a banner") |
| + } |
| + _, _, body = get(t, c, fx.u("/billing?pending=1")) |
| + if !strings.Contains(body, billingPendingCopy) { |
| + t.Fatalf("?pending=1 banner missing:\n%s", mainOf(t, body)) |
| + } |
| +} |
| + |
| +// 6. A reconciled subscription: the plan line with quantity and renewal, the |
| +// portal form, and no chooser the checkout door would only refuse. A stale |
| +// ?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 { |
| + t.Fatal(err) |
| + } |
| + f.mu.Lock() |
| + f.sessionStatus = "complete" |
| + f.mu.Unlock() |
| + if code := f.webhookPost(t, fx, "evt_page_active", "customer.subscription.updated", "sub_paid"); code != http.StatusNoContent { |
| + t.Fatalf("webhook: %d", code) |
| + } |
| + state, err := fx.st.billingAccount(acct.ID) |
| + if err != nil || state.Status != "active" { |
| + t.Fatalf("state %+v %v", state, err) |
| + } |
| + renews := time.Unix(state.PeriodEnd, 0).UTC().Format("2006-01-02") |
| + |
| + c := signInPaul(t, fx) |
| + status, _, body := get(t, c, fx.u("/billing?pending=1")) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET /billing = %d", status) |
| + } |
| + m := mainOf(t, body) |
| + for _, want := range []string{ |
| + "Personal, 1 members, renews on " + renews + ".", |
| + `method="post" action="/a/` + fx.sqid + `/billing/portal"`, |
| + "Open billing at Stripe", |
| + } { |
| + if !strings.Contains(m, want) { |
| + t.Fatalf("active page missing %q in:\n%s", want, m) |
| + } |
| + } |
| + for _, not := range []string{billingFreeCopy, "/billing/checkout", billingPendingCopy, "cus_paid"} { |
| + if strings.Contains(m, not) { |
| + t.Fatalf("active page shows %q:\n%s", not, m) |
| + } |
| + } |
| + |
| + // A member of the same account sees the plan, not the portal. |
| + if err := fx.st.AddMember(acct.ID, "kim@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + member, _ := signInAs(t, fx, "kim@keymail.dev", "code-billing-member-active") |
| + _, _, body = get(t, member, fx.u("/billing")) |
| + m = mainOf(t, body) |
| + if !strings.Contains(m, "Personal, 1 members, renews on "+renews+".") || strings.Contains(m, "<form") { |
| + t.Fatalf("member view of an active plan:\n%s", m) |
| + } |
| + |
| + // Past due keeps the plan line and asks for a card update. |
| + f.mu.Lock() |
| + f.subscriptionStatus = "past_due" |
| + f.mu.Unlock() |
| + if code := f.webhookPost(t, fx, "evt_page_past_due", "customer.subscription.updated", "sub_paid"); code != http.StatusNoContent { |
| + t.Fatalf("past_due webhook: %d", code) |
| + } |
| + _, _, body = get(t, c, fx.u("/billing")) |
| + m = mainOf(t, body) |
| + if !strings.Contains(m, "The last payment did not go through. Update your card below to keep the plan.") || !strings.Contains(m, "/billing/portal") { |
| + t.Fatalf("past_due page:\n%s", m) |
| + } |
| +} |
| + |
| +// An open review hold is said; a resolved one is not. |
| +func TestBillingPageReviewHold(t *testing.T) { |
| + fx, _, acct := newBillingFixture(t) |
| + const review = "Something about this account's billing needs a person to look at it." |
| + c := signInPaul(t, fx) |
| + _, _, body := get(t, c, fx.u("/billing")) |
| + if strings.Contains(body, review) { |
| + t.Fatal("review line with no hold") |
| + } |
| + if _, err := fx.st.db.Exec(`INSERT INTO billing_reviews(account_id,subscription,reason,created_at) VALUES(?,?,?,?)`, acct.ID, "sub_x", "test_reason", time.Now().Unix()); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, _, body = get(t, c, fx.u("/billing")) |
| + if !strings.Contains(html2text(body), review) { |
| + t.Fatalf("open hold not shown:\n%s", mainOf(t, body)) |
| + } |
| + if _, err := fx.st.db.Exec(`UPDATE billing_reviews SET resolved_at=? WHERE account_id=?`, time.Now().Unix(), acct.ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, _, body = get(t, c, fx.u("/billing")) |
| + if strings.Contains(html2text(body), review) { |
| + t.Fatal("resolved hold still shown") |
| + } |
| +} |
| + |
| +// html2text undoes the apostrophe escaping html/template applies to text. |
| +func html2text(s string) string { return strings.ReplaceAll(s, "'", "'") } |
| + |
| +func TestBillingPagePriceFormatting(t *testing.T) { |
| + for micro, want := range map[int64]string{ |
| + 0: "€0", 24_000_000: "€24", 18_000_000: "€18", 8_400_000: "€8.40", 1_750_000: "€1.75", 50_000: "€0.05", 500_000_000: "€500", |
| + } { |
| + if got := formatEuroMicro(micro); got != want { |
| + t.Errorf("formatEuroMicro(%d) = %q, want %q", micro, got, want) |
| + } |
| + } |
| + rows := billingPlanRows(plans.BandD, 3) |
| + if len(rows) != 3 { |
| + t.Fatalf("rows = %+v", rows) |
| + } |
| + if rows[0].Plan != "personal" || rows[0].Price != "€8.40" || rows[0].Team { |
| + t.Fatalf("personal at D = %+v", rows[0]) |
| + } |
| + if rows[1].Plan != "community" || rows[1].PerMember != "€1.75" || rows[1].Price != "€17.50" || !rows[1].Team { |
| + t.Fatalf("community at D, 3 members (billed as 10) = %+v", rows[1]) |
| + } |
| + for band, want := range map[string]int64{plans.BandA: 100, plans.BandB: 75, plans.BandC: 50, plans.BandD: 35} { |
| + if got, ok := billingBandPercent(band); !ok || got != want { |
| + t.Errorf("billingBandPercent(%s) = %d %v, want %d", band, got, ok, want) |
| + } |
| + } |
| + if _, ok := billingBandPercent("Z"); ok { |
| + t.Error("unknown band priced") |
| + } |
| +} |
| + |
| +// The country list follows the deployment's configured bands when billing |
| +// is on, and the embedded terms when it is off. |
| +func TestBillingPageCountriesSource(t *testing.T) { |
| + fx, _, _ := newBillingFixture(t) |
| + got := fx.srv.billingCountries() |
| + if len(got) != 1 || got[0].ISO2 != "IE" || got[0].Name != "Ireland" || got[0].Band != plans.BandA { |
| + t.Fatalf("configured countries = %+v", got) |
| + } |
| + fx.srv.billing = nil |
| + got = fx.srv.billingCountries() |
| + if len(got) != len(plans.CountryBands()) { |
| + t.Fatalf("embedded countries = %d, want %d", len(got), len(plans.CountryBands())) |
| + } |
| + for i := 1; i < len(got); i++ { |
| + if got[i-1].Name > got[i].Name { |
| + t.Fatalf("not sorted by name at %d: %q > %q", i, got[i-1].Name, got[i].Name) |
| + } |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 6d6ea4b06..874b561ea 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -1125,6 +1125,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("GET /a/{sqid}/fleet/byo", s.handleFleetBYO) |
| s.mux.HandleFunc("GET /a/{sqid}/team", s.handleTeam) |
| s.mux.HandleFunc("GET /a/{sqid}/settings", s.handleAccountSettings) |
| + s.mux.HandleFunc("GET /a/{sqid}/billing", s.handleBillingPage) |
| s.mux.HandleFunc("/a/{sqid}/billing/checkout", s.handleBillingCheckout) |
| s.mux.HandleFunc("/a/{sqid}/billing/portal", s.handleBillingPortal) |
| s.mux.HandleFunc("/billing/webhook", s.handleBillingWebhook) |
| @@ -1350,7 +1351,10 @@ type page struct { |
| AccountReview AccountReview |
| MismatchCopy MismatchCopy |
| - ActiveNav string // account nav section this page sits in: "apps" | "fleet" | "team" | "settings" |
| + ActiveNav string // account nav section this page sits in: "apps" | "fleet" | "team" | "billing" | "settings" |
| + |
| + // /a/{sqid}/billing (billingpage.go): nil on every other page. |
| + Billing *billingPageView |
| ActiveTab string // app pages: "overview" | "releases" | "instances" | "activity" | "settings" |
| AppName string // app pages: the {app} segment, for the tab bar and eyebrow |
diff --git a/internal/console/templates/billing.html b/internal/console/templates/billing.html| new file mode 100644 |
| index 000000000..3eec5d1dc |
| --- /dev/null |
| +++ b/internal/console/templates/billing.html |
| @@ -0,0 +1,98 @@ |
| +{{define "billing"}}{{template "top" .}} |
| +{{/* Every user-facing string below is DRAFT copy under review, applied |
| + verbatim from implementation/carloku-paid-launch/copy-review/billing-page/strings.json. |
| + The HTML comment beside each one is its copy id, so an approved edit can |
| + be applied byte for byte. Strings marked "not in strings.json" have no |
| + reviewed draft yet. */}} |
| +{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| +{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| +{{with .Billing}} |
| +{{if eq .Error "unavailable"}}<p class="banner error"><!-- billing.error.unavailable -->Billing is not available right now. Nothing was charged. Try again in a few minutes.</p>{{end}} |
| +{{if eq .Error "pending"}}<p class="banner error"><!-- billing.error.pending -->An earlier checkout for this account is still completing. Wait a minute and reload this page before trying again.</p>{{end}} |
| +{{if eq .Error "conflict"}}<p class="banner error"><!-- billing.error.conflict -->The account changed while you were on this page, for example a member was added. Check the plan and try again.</p>{{end}} |
| +{{if .PendingCheckout}}<p class="banner"><!-- billing.current.pending -->Thanks. Stripe is confirming your payment. Your plan turns on within a few minutes, and this page will show it.</p>{{end}} |
| +{{end}} |
| +<div class="pagehead"> |
| + <p class="eyebrow">Account · {{.Account.Name}}</p> |
| + <h1><!-- billing.title -->Billing</h1> |
| + <p class="lede"><!-- billing.lede -->Your plan, what it includes, and how to change it.</p> |
| +</div> |
| +{{with .Billing}} |
| +<h2><!-- billing.current.heading -->Your plan</h2> |
| +{{if or .Active .PastDue}} |
| +<p><!-- billing.current.active -->{{.PlanName}}, {{.Quantity}} members, renews on {{.Renews}}.</p> |
| +{{else}} |
| +<p><!-- billing.current.free -->Free. One member, 3 apps, 100 awake hours a month, 250 MB of storage and 1 GB of transfer. Apps sleep after 2 minutes idle.</p> |
| +{{end}} |
| +{{/* billing.current.ending is not rendered: nothing this console records |
| + says a subscription is set to cancel at period end. */}} |
| +{{if .PastDue}}<p class="banner error"><!-- billing.current.pastdue -->The last payment did not go through. Update your card below to keep the plan.</p>{{end}} |
| +{{if .Review}}<p class="banner"><!-- billing.current.review -->Something about this account's billing needs a person to look at it. Nothing is charged while that happens. We will be in touch.</p>{{end}} |
| + |
| +{{if .Closed}} |
| +<p><!-- billing.closed -->Paid plans are not open on this console yet. Every account is on the Free plan.</p> |
| +{{else if not .Owner}} |
| +<p class="small"><!-- billing.member.readonly -->Only an account owner can change the plan.</p> |
| +{{else}} |
| +{{if .CanChoose}} |
| +<h2><!-- billing.choose.heading -->Choose a plan</h2> |
| +<p class="small"><!-- billing.choose.lede -->Prices are per year, before VAT, and depend on your billing country. The exact price is shown before you pay.</p> |
| + |
| +<form class="signin" method="get" action="{{$.Base}}/billing"> |
| + <label><!-- billing.country.label -->Billing country |
| + <select name="country"> |
| + <option value=""><!-- billing.country.placeholder -->Choose a country</option> |
| + {{range .Countries}}<option value="{{.ISO2}}"{{if .Selected}} selected{{end}}>{{.Name}}</option> |
| + {{end}} |
| + </select> |
| + </label> |
| + <button class="linkish" type="submit"><!-- billing.quote.button -->Show prices for this country</button> |
| +</form> |
| +{{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 method="post" action="{{$.Base}}/billing/checkout"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <div class="tablewrap"> |
| + <table> |
| + {{range .Plans}} |
| + <tr> |
| + <td><label><input type="radio" name="plan" value="{{.Plan}}" required> |
| + {{if eq .Plan "personal"}}<!-- billing.plan.personal.name -->Personal{{else if eq .Plan "community"}}<!-- billing.plan.community.name -->Community{{else if eq .Plan "business"}}<!-- billing.plan.business.name -->Business{{end}}</label></td> |
| + <td>{{if .Team}}<strong>{{.PerMember}}</strong> <!-- billing.price.permember -->per member a year |
| + <br><span class="small">{{.Price}} <!-- billing.price.peryear -->a year</span>{{else}}<strong>{{.Price}}</strong> <!-- billing.price.peryear -->a year{{end}}</td> |
| + <td class="small">{{if eq .Plan "personal"}}<!-- billing.plan.personal.blurb -->One member. Each month: 85 awake hours, 2 GB storage, 2 GB transfer.{{else if eq .Plan "community"}}<!-- billing.plan.community.blurb -->Per member, minimum 10. Each member adds, per month: 17 awake hours, 0.5 GB storage, 0.4 GB transfer.{{else if eq .Plan "business"}}<!-- billing.plan.business.blurb -->Per member, minimum 10. Each member adds, per month: 170 awake hours, 5 GB storage, 3 GB transfer, 30,000 replication writes. Priority support.{{end}}</td> |
| + </tr> |
| + {{end}} |
| + </table> |
| + </div> |
| + <p class="small"><!-- billing.members.note -->Community and Business are priced per member. You have {{.Members}} members; the minimum is 10.</p> |
| + <p><label><!-- billing.country.label -->Billing country |
| + <select name="country" required> |
| + <option value=""><!-- billing.country.placeholder -->Choose a country</option> |
| + {{range .Countries}}<option value="{{.ISO2}}"{{if .Selected}} selected{{end}}>{{.Name}}</option> |
| + {{end}} |
| + </select> |
| + </label></p> |
| + <p><label><!-- billing.postal.label -->Postal code |
| + <input type="text" name="postal" autocomplete="postal-code"> |
| + </label></p> |
| + <p class="small"><!-- billing.postal.help -->Needed in the United States, Canada and India, where tax depends on it. Leave it empty elsewhere. We do not keep it.</p> |
| + <p><label><input type="checkbox" name="consent" value="{{.TermsVersion}}" required> |
| + <!-- 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> |
| +</form> |
| +{{end}} |
| + |
| +{{if .Customer}} |
| +<h2><!-- billing.manage.heading -->Cards, invoices and cancelling</h2> |
| +<p class="small"><!-- billing.manage.body -->Change your card, download invoices or cancel at Stripe. Cancelling keeps the plan until the end of the year you paid for.</p> |
| +<form method="post" action="{{$.Base}}/billing/portal"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <button type="submit"><!-- billing.manage.button -->Open billing at Stripe</button> |
| +</form> |
| +{{end}} |
| +{{end}} |
| +{{end}} |
| +{{template "bottom" .}}{{end}} |
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html| index 69a2b123c..31b085b5d 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -565,6 +565,7 @@ |
| <a href="{{.Nav}}/"{{if eq .ActiveNav "apps"}} aria-current="page"{{end}}>Apps</a> |
| <a href="{{.Nav}}/fleet"{{if eq .ActiveNav "fleet"}} aria-current="page"{{end}}>Fleet</a> |
| <a href="{{.Nav}}/team"{{if eq .ActiveNav "team"}} aria-current="page"{{end}}>Team</a> |
| + <!-- billing.nav --><a href="{{.Nav}}/billing"{{if eq .ActiveNav "billing"}} aria-current="page"{{end}}>Billing</a> |
| <a href="{{.Nav}}/settings"{{if eq .ActiveNav "settings"}} aria-current="page"{{end}}>Settings</a> |
| {{end}} |
| <a class="addr" href="/settings" title="Your settings">{{.Address}}</a> |
diff --git a/internal/plans/countries.go b/internal/plans/countries.go| index 92ebe7b44..96131435f 100644 |
| --- a/internal/plans/countries.go |
| +++ b/internal/plans/countries.go |
| @@ -30,6 +30,7 @@ import ( |
| "io" |
| "math/big" |
| "regexp" |
| + "sort" |
| ) |
| //go:embed countries.json |
| @@ -275,3 +276,27 @@ func CountryBands() map[string]string { |
| } |
| return out |
| } |
| + |
| +// Country is one embedded country record as a picker needs it: the ISO |
| +// 3166-1 alpha-2 code, its English display name, and its band under |
| +// CurrentCountryTermsVersion. |
| +type Country struct { |
| + ISO2, Name, Band string |
| +} |
| + |
| +// Countries returns every embedded country for CurrentCountryTermsVersion, |
| +// sorted by Name (then ISO2, so the order is total). The slice is freshly |
| +// built on each call and never aliases package state. |
| +func Countries() []Country { |
| + out := make([]Country, 0, len(countryTerms.Countries)) |
| + for _, c := range countryTerms.Countries { |
| + out = append(out, Country{ISO2: c.ISO2, Name: c.Name, Band: c.Band}) |
| + } |
| + sort.Slice(out, func(i, j int) bool { |
| + if out[i].Name != out[j].Name { |
| + return out[i].Name < out[j].Name |
| + } |
| + return out[i].ISO2 < out[j].ISO2 |
| + }) |
| + return out |
| +} |
diff --git a/internal/plans/countries_test.go b/internal/plans/countries_test.go| index 48a063fdd..2c8c4e33a 100644 |
| --- a/internal/plans/countries_test.go |
| +++ b/internal/plans/countries_test.go |
| @@ -298,3 +298,38 @@ func TestCountryBandsCountAndCoverage(t *testing.T) { |
| } |
| t.Logf("country count = %d, band distribution = %+v", len(all), counts) |
| } |
| + |
| +// TestCountriesListsEveryBandedCountryByName pins the picker accessor to the |
| +// band map: same set, same bands, every name present, sorted by name, and a |
| +// fresh slice per call. |
| +func TestCountriesListsEveryBandedCountryByName(t *testing.T) { |
| + all := Countries() |
| + bands := CountryBands() |
| + if len(all) != len(bands) { |
| + t.Fatalf("Countries() has %d entries, CountryBands() has %d", len(all), len(bands)) |
| + } |
| + for i, c := range all { |
| + if c.Name == "" { |
| + t.Fatalf("%s has no name", c.ISO2) |
| + } |
| + if bands[c.ISO2] != c.Band { |
| + t.Fatalf("%s band %q, CountryBands says %q", c.ISO2, c.Band, bands[c.ISO2]) |
| + } |
| + if i > 0 && all[i-1].Name > c.Name { |
| + t.Fatalf("not sorted by name: %q before %q", all[i-1].Name, c.Name) |
| + } |
| + } |
| + var ie *Country |
| + for i := range all { |
| + if all[i].ISO2 == "IE" { |
| + ie = &all[i] |
| + } |
| + } |
| + if ie == nil || ie.Name != "Ireland" || ie.Band != BandA { |
| + t.Fatalf("IE = %+v, want Ireland in band A", ie) |
| + } |
| + all[0].Name = "mutated" |
| + if Countries()[0].Name == "mutated" { |
| + t.Fatal("Countries() aliases package state") |
| + } |
| +} |