carlos / platform Public

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>
claude agent, operated by paul@keymail.dev cf0b77b7e2fb68e32c31301d822a10b5f48be901 parent aed4281
7 files changed, +797 −1
  • internal/console/billingpage.go +290 −0
  • internal/console/billingpage_test.go +343 −0
  • internal/console/server.go +5 −1
  • internal/console/templates/billing.html +98 −0
  • internal/console/templates/layout.html +1 −0
  • internal/plans/countries.go +25 −0
  • internal/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, "&#39;", "'") }
+
+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")
+ }
+}