rastrillo / abono Public

abono: billing per Stripe customer, so every charge a reader pays has a way to manage it

Summary

Adds (*Service) Customers(ctx, subject), every Stripe customer of the subject's subscription rows, recognised price or not, newest first. Portal becomes Portal(ctx, subject, customerID, returnPath) and refuses, with ErrNoCustomer, a customer that is not the subject's. The billing-button partial takes an optional Customer, posted as customer. The generated account section shows one Manage billing button per customer, independent of Holdings, so a reader on a price no plan has, or with several customers, can still reach every subscription; the billing handler passes the posted customer.
Agent notes
Final review I4 and I5, ruling R37. Breaking change to Portal's signature (pre-release). Customers reads subscription rows only, as the ruling says; a bare abono_customers mapping no longer opens the portal. Several customers show several identical Manage billing buttons (no new copy). Example pages regenerated. Regressions: TestPortalIsPerCustomer (three customers, one ended, one unrecognised; someone else's refused) and example TestBillingStaysAfterSwitchingToAnUnknownPrice.
claude agent, operated by paul@keymail.dev d6921b9b102815163ceb0aadbadbc1c078e04eb8 parent e89cae1
12 files changed, +207 −58
  • abonotest/abonotest_test.go +13 −3
  • checkout.go +29 −6
  • checkout_test.go +52 −15
  • cmd/abono/pages/abono_pages.go.tmpl +17 −9
  • cmd/abono/pages/account.html.tmpl +5 −4
  • example/abono_pages.go +17 −9
  • example/app_test.go +46 −2
  • example/templates/abono/account.html +5 −4
  • sandbox_test.go +12 −2
  • templates.go +1 −1
  • templates/abono-billing-button.html +8 −3
  • templates_test.go +2 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./... -count=1
ok  	amadan.net/rastrillo/abono	2.554s
ok  	amadan.net/rastrillo/abono/abonotest	0.051s
ok  	amadan.net/rastrillo/abono/cmd/abono	0.606s
ok  	amadan.net/rastrillo/abono/internal/fakestripe	0.019s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	0.006s
ok  	example	0.136s
=== race ===
CGO_ENABLED=1 go test ./... -race -count=1
ok  	amadan.net/rastrillo/abono	7.800s
ok  	amadan.net/rastrillo/abono/abonotest	1.374s
ok  	amadan.net/rastrillo/abono/cmd/abono	1.795s
ok  	amadan.net/rastrillo/abono/internal/fakestripe	1.058s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	1.018s
ok  	example	1.832s
=== browser ===
ok  	example	0.698s
diff --git a/abonotest/abonotest_test.go b/abonotest/abonotest_test.go
index 27dead4..d895597 100644
--- a/abonotest/abonotest_test.go
+++ b/abonotest/abonotest_test.go
@@ -180,6 +180,16 @@ func (a *app) wantRoles(when string, want map[string]string) {
}
}
+// customer is subject's one Stripe customer.
+func (a *app) customer(subject string) string {
+ a.t.Helper()
+ cs, err := a.s.Customers(context.Background(), subject)
+ if err != nil || len(cs) != 1 {
+ a.t.Fatalf("Customers(%s) = %q, %v; want one", subject, cs, err)
+ }
+ return cs[0]
+}
+
func (a *app) holdings(subject string) []abono.Holding {
a.t.Helper()
hs, err := a.s.Holdings(context.Background(), subject)
@@ -275,7 +285,7 @@ func TestAbonotestEndToEnd(t *testing.T) {
}
// They cancel in the portal: still theirs until the period ends.
- pu, err := a.s.Portal(ctx, reader, "/account")
+ pu, err := a.s.Portal(ctx, reader, a.customer(reader), "/account")
if err != nil {
t.Fatalf("Portal: %v", err)
}
@@ -345,7 +355,7 @@ func TestAbonotestEndToEnd(t *testing.T) {
if _, err := a.s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual"}); err == nil {
t.Error("Checkout succeeded with no Stripe")
}
- if _, err := a.s.Portal(ctx, "someone@example.com", "/account"); err == nil {
+ if _, err := a.s.Portal(ctx, "someone@example.com", "cus_1", "/account"); err == nil {
t.Error("Portal succeeded with no Stripe")
}
body := []byte(`{"id":"evt_1","type":"customer.subscription.updated","data":{"object":{"id":"sub_1"}}}`)
@@ -369,7 +379,7 @@ func TestAbonotestEndToEnd(t *testing.T) {
eq(t, "declined", declined, []string{owner})
a.wantRoles("subscribed", map[string]string{owner: "owner"})
- pu, err := a.s.Portal(ctx, owner, "/account")
+ pu, err := a.s.Portal(ctx, owner, a.customer(owner), "/account")
if err != nil {
t.Fatalf("Portal: %v", err)
}
diff --git a/checkout.go b/checkout.go
index def1862..63e5fa5 100644
--- a/checkout.go
+++ b/checkout.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/url"
+ "slices"
"strings"
"gorm.io/gorm"
@@ -16,7 +17,8 @@ var (
// ErrUnknownPlan is a plan not for sale, or not at the interval asked
// for, or sold at several intervals when none was asked for.
ErrUnknownPlan = errors.New("abono: no such plan for sale")
- // ErrNoCustomer is a subject with nothing in Stripe to manage.
+ // ErrNoCustomer is a subject with nothing in Stripe to manage, or a
+ // Stripe customer that is not the subject's.
ErrNoCustomer = errors.New("abono: no Stripe customer")
errOff = errors.New("abono: Stripe is not configured")
@@ -292,9 +294,29 @@ func (s *Service) Finish(ctx context.Context, sessionID string) (Finished, error
return f, nil
}
-// Portal is a Customer Portal session for subject, where they change their
-// card or plan or cancel, coming back to returnPath on this site.
-func (s *Service) Portal(ctx context.Context, subject, returnPath string) (string, error) {
+// Customers is every Stripe customer subject pays through, newest
+// subscription first: the customer of each of the subject's subscription
+// rows, whether or not it grants and whether or not an offer recognises
+// its price (ruling R37). A reader with two guest purchases, or one on a
+// price no plan has, is still charged by each, so a page offers billing
+// for every one of them, not for what Holdings shows.
+func (s *Service) Customers(ctx context.Context, subject string) ([]string, error) {
+ subject = strings.TrimSpace(subject)
+ if subject == "" {
+ return nil, nil
+ }
+ var ids []string
+ err := s.cfg.DB.WithContext(ctx).Raw(`SELECT stripe_customer_id FROM abono_subscriptions
+ WHERE subject = ? AND stripe_customer_id <> ''
+ GROUP BY stripe_customer_id ORDER BY MAX(created_at) DESC, stripe_customer_id DESC`, subject).Scan(&ids).Error
+ return ids, err
+}
+
+// Portal is a Customer Portal session for customer, one of subject's
+// Customers, where they change their card or plan or cancel, coming back
+// to returnPath on this site. A customer that is not one of subject's is
+// ErrNoCustomer, so a posted id cannot open someone else's billing.
+func (s *Service) Portal(ctx context.Context, subject, customer, returnPath string) (string, error) {
if !s.On() {
return "", errOff
}
@@ -302,11 +324,12 @@ func (s *Service) Portal(ctx context.Context, subject, returnPath string) (strin
if err != nil {
return "", err
}
- customer, err := customerOf(s.cfg.DB.WithContext(ctx), strings.TrimSpace(subject))
+ customers, err := s.Customers(ctx, subject)
if err != nil {
return "", err
}
- if customer == "" {
+ customer = strings.TrimSpace(customer)
+ if customer == "" || !slices.Contains(customers, customer) {
return "", ErrNoCustomer
}
return s.stripe.PortalURL(ctx, customer, back)
diff --git a/checkout_test.go b/checkout_test.go
index 2afae9c..923bfe3 100644
--- a/checkout_test.go
+++ b/checkout_test.go
@@ -6,6 +6,7 @@ import (
"net/url"
"reflect"
"testing"
+ "time"
"amadan.net/rastrillo/abono"
)
@@ -216,30 +217,66 @@ func TestCheckoutSuccessURL(t *testing.T) {
}
}
-func TestPortalWithoutCustomer(t *testing.T) {
+// TestPortalIsPerCustomer is final review I4 and I5 (ruling R37). A
+// reader may pay through several Stripe customers (two guest purchases, an
+// imported member), and a subscription on a price no plan has is still
+// charged. Customers lists every customer of the subject's subscription
+// rows, recognised or not, and Portal opens any of them, and only them.
+func TestPortalIsPerCustomer(t *testing.T) {
now := t0
d := newStripeDouble(t)
- s, db := d.service(t, &now, abono.Config{Origin: "https://site.example"})
+ s, _ := d.service(t, &now, abono.Config{Origin: "https://site.example"})
+ seedPlans(t, s)
ctx := context.Background()
- if _, err := s.Portal(ctx, "5", "/account"); !errors.Is(err, abono.ErrNoCustomer) {
- t.Errorf("Portal with no customer: err = %v, want ErrNoCustomer", err)
+ if _, err := s.Portal(ctx, "5", "cus_a", "/account"); !errors.Is(err, abono.ErrNoCustomer) {
+ t.Errorf("Portal with no subscription: err = %v, want ErrNoCustomer", err)
}
- if _, err := s.Portal(ctx, "", "/account"); !errors.Is(err, abono.ErrNoCustomer) {
- t.Errorf("Portal for nobody: err = %v, want ErrNoCustomer", err)
+ if cs, err := s.Customers(ctx, "5"); err != nil || len(cs) != 0 {
+ t.Errorf("Customers with no subscription = %q, %v", cs, err)
}
- db.exec(`INSERT INTO abono_customers (stripe_customer_id, subject) VALUES ('cus_1', '5')`)
- u, err := s.Portal(ctx, "5", "/account")
+ // An older customer still paying, a newer one whose subscription has
+ // ended, a third on a price no plan has, and someone else's.
+ sync := func(sp subSpec, subject string) {
+ t.Helper()
+ if err := s.Sync(ctx, d.put(t, sp), abono.Hint{Subject: subject}); err != nil {
+ t.Fatalf("Sync %s: %v", sp.ID, err)
+ }
+ now = now.Add(time.Minute)
+ }
+ sync(subSpec{ID: "sub_a", Status: "active", Price: "price_A", Customer: "cus_a", Email: "five@example.com", End: t0.Add(300 * day)}, "5")
+ sync(subSpec{ID: "sub_b", Status: "active", Price: "price_M", Customer: "cus_b", Email: "five@example.com", End: t0.Add(30 * day)}, "5")
+ sync(subSpec{ID: "sub_b", Status: "canceled", Price: "price_M", Customer: "cus_b", Email: "five@example.com", End: t0.Add(30 * day)}, "5")
+ sync(subSpec{ID: "sub_c", Status: "active", Price: "price_Z", Customer: "cus_c", Email: "five@example.com", End: t0.Add(30 * day)}, "5")
+ sync(subSpec{ID: "sub_x", Status: "active", Price: "price_A", Customer: "cus_x", Email: "six@example.com", End: t0.Add(30 * day)}, "6")
+
+ cs, err := s.Customers(ctx, "5")
if err != nil {
- t.Fatalf("Portal: %v", err)
+ t.Fatalf("Customers: %v", err)
}
- if u != d.URL+"/portal/cus_1" {
- t.Errorf("Portal URL = %q", u)
+ if want := []string{"cus_c", "cus_b", "cus_a"}; !reflect.DeepEqual(cs, want) {
+ t.Errorf("Customers(5) = %q, want %q (newest subscription first)", cs, want)
}
- portals := d.PortalForms()
- got := portals[len(portals)-1]
- if got.Get("customer") != "cus_1" || got.Get("return_url") != "https://site.example/account" {
- t.Errorf("portal form = %v", got)
+ for _, c := range []string{"cus_a", "cus_b", "cus_c"} {
+ u, err := s.Portal(ctx, "5", c, "/account")
+ if err != nil {
+ t.Fatalf("Portal(5, %s): %v", c, err)
+ }
+ if u != d.URL+"/portal/"+c {
+ t.Errorf("Portal(5, %s) = %q", c, u)
+ }
+ forms := d.PortalForms()
+ if got := forms[len(forms)-1]; got.Get("customer") != c || got.Get("return_url") != "https://site.example/account" {
+ t.Errorf("portal form = %v", got)
+ }
+ }
+ for _, c := range []string{"cus_x", "", "cus_nobody"} {
+ if _, err := s.Portal(ctx, "5", c, "/account"); !errors.Is(err, abono.ErrNoCustomer) {
+ t.Errorf("Portal(5, %q): err = %v, want ErrNoCustomer", c, err)
+ }
+ }
+ if _, err := s.Portal(ctx, "", "cus_a", "/account"); !errors.Is(err, abono.ErrNoCustomer) {
+ t.Errorf("Portal for nobody: err = %v, want ErrNoCustomer", err)
}
}
diff --git a/cmd/abono/pages/abono_pages.go.tmpl b/cmd/abono/pages/abono_pages.go.tmpl
index 4f83995..bdfeded 100644
--- a/cmd/abono/pages/abono_pages.go.tmpl
+++ b/cmd/abono/pages/abono_pages.go.tmpl
@@ -298,8 +298,9 @@ func (p *AbonoPages) done(w http.ResponseWriter, r *http.Request) {
p.render(w, r, http.StatusOK, "abono/done", data)
}
-// POST /account/billing: the signed-in reader's billing portal, coming
-// back to the account page. A failure goes back there too, saying why.
+// POST /account/billing: the signed-in reader's billing portal for the
+// posted customer, one of theirs, coming back to the account page. A
+// failure goes back there too, saying why.
func (p *AbonoPages) billing(w http.ResponseWriter, r *http.Request) {
back := func(problem string) {
http.Redirect(w, r, abonoAccountPath+"?billing="+problem, http.StatusSeeOther)
@@ -313,7 +314,7 @@ func (p *AbonoPages) billing(w http.ResponseWriter, r *http.Request) {
back("off")
return
}
- u, err := p.Service.Portal(r.Context(), subject, abonoAccountPath)
+ u, err := p.Service.Portal(r.Context(), subject, r.PostFormValue("customer"), abonoAccountPath)
switch {
case errors.Is(err, abono.ErrNoCustomer):
back("none")
@@ -335,9 +336,10 @@ var abonoBillingProblems = map[string]string{
}
// Account is the data for the "abono-account" section of your account
-// page: the reader's holdings, the billing button when they pay through
-// Stripe, and the problem the billing button came back with. Add the
-// request's localizer before rendering it:
+// page: the reader's holdings, a billing button for each Stripe customer
+// they pay through (even one whose subscription no plan recognises: it is
+// still charged), and the problem the billing button came back with. Add
+// the request's localizer before rendering it:
//
// {{template "abono-account" (withL .Abono $L)}}
func (p *AbonoPages) Account(r *http.Request) (map[string]any, error) {
@@ -346,15 +348,21 @@ func (p *AbonoPages) Account(r *http.Request) (map[string]any, error) {
if err != nil {
return nil, err
}
- manage, granted := false, false
+ granted := false
for _, h := range holdings {
- manage = manage || h.Source == abono.SourceSubscription
granted = granted || h.Grants
}
+ var customers []string
+ if p.Service.On() {
+ if customers, err = p.Service.Customers(r.Context(), subject); err != nil {
+ return nil, err
+ }
+ }
return map[string]any{
"Holdings": holdings,
"Now": p.Service.Now(),
- "Manage": manage && p.Service.On(),
+ "Manage": len(customers) > 0,
+ "Customers": customers,
"Granted": granted,
"BillingAction": abonoBillingPath,
"PlansURL": abonoPricingPath,
diff --git a/cmd/abono/pages/account.html.tmpl b/cmd/abono/pages/account.html.tmpl
index 556ca9f..fe34f0d 100644
--- a/cmd/abono/pages/account.html.tmpl
+++ b/cmd/abono/pages/account.html.tmpl
@@ -1,6 +1,6 @@
{{/* abono-account: the membership section of your own account page, not
- a page of its own. Each holding as a plan and a status line, the
- billing button for a reader who pays through Stripe, a link to the
+ a page of its own. Each holding as a plan and a status line, a
+ billing button for each Stripe customer the reader pays through, a link to the
plans for one who holds nothing that grants, and the problem the
billing button came back with.
@@ -11,6 +11,7 @@
Holdings []abono.Holding, newest first
Now time.Time, the moment they are described at
Manage bool, there is billing to manage
+ Customers []string, the reader's Stripe customers, one button each
Granted bool, something grants access now
BillingAction string, where the billing button posts
PlansURL string, the pricing page
@@ -25,8 +26,8 @@
{{else}}<p>{{T $L "abono.pages.account_none"}}</p>
{{end}}
{{if .Manage}}<p>{{T $L "abono.pages.account_manage_help"}}</p>
-{{template "abono-billing-button" dict "L" $L "Action" .BillingAction}}
-{{end}}
+{{range .Customers}}{{template "abono-billing-button" dict "L" $L "Action" $.BillingAction "Customer" .}}
+{{end}}{{end}}
{{if not .Granted}}<p><a rst-btn="primary" href="{{.PlansURL}}">{{T $L "abono.button.see_plans"}}</a></p>
{{end}}
</section>{{end}}
diff --git a/example/abono_pages.go b/example/abono_pages.go
index c1db06e..c6ccfd7 100644
--- a/example/abono_pages.go
+++ b/example/abono_pages.go
@@ -298,8 +298,9 @@ func (p *AbonoPages) done(w http.ResponseWriter, r *http.Request) {
p.render(w, r, http.StatusOK, "abono/done", data)
}
-// POST /account/billing: the signed-in reader's billing portal, coming
-// back to the account page. A failure goes back there too, saying why.
+// POST /account/billing: the signed-in reader's billing portal for the
+// posted customer, one of theirs, coming back to the account page. A
+// failure goes back there too, saying why.
func (p *AbonoPages) billing(w http.ResponseWriter, r *http.Request) {
back := func(problem string) {
http.Redirect(w, r, abonoAccountPath+"?billing="+problem, http.StatusSeeOther)
@@ -313,7 +314,7 @@ func (p *AbonoPages) billing(w http.ResponseWriter, r *http.Request) {
back("off")
return
}
- u, err := p.Service.Portal(r.Context(), subject, abonoAccountPath)
+ u, err := p.Service.Portal(r.Context(), subject, r.PostFormValue("customer"), abonoAccountPath)
switch {
case errors.Is(err, abono.ErrNoCustomer):
back("none")
@@ -335,9 +336,10 @@ var abonoBillingProblems = map[string]string{
}
// Account is the data for the "abono-account" section of your account
-// page: the reader's holdings, the billing button when they pay through
-// Stripe, and the problem the billing button came back with. Add the
-// request's localizer before rendering it:
+// page: the reader's holdings, a billing button for each Stripe customer
+// they pay through (even one whose subscription no plan recognises: it is
+// still charged), and the problem the billing button came back with. Add
+// the request's localizer before rendering it:
//
// {{template "abono-account" (withL .Abono $L)}}
func (p *AbonoPages) Account(r *http.Request) (map[string]any, error) {
@@ -346,15 +348,21 @@ func (p *AbonoPages) Account(r *http.Request) (map[string]any, error) {
if err != nil {
return nil, err
}
- manage, granted := false, false
+ granted := false
for _, h := range holdings {
- manage = manage || h.Source == abono.SourceSubscription
granted = granted || h.Grants
}
+ var customers []string
+ if p.Service.On() {
+ if customers, err = p.Service.Customers(r.Context(), subject); err != nil {
+ return nil, err
+ }
+ }
return map[string]any{
"Holdings": holdings,
"Now": p.Service.Now(),
- "Manage": manage && p.Service.On(),
+ "Manage": len(customers) > 0,
+ "Customers": customers,
"Granted": granted,
"BillingAction": abonoBillingPath,
"PlansURL": abonoPricingPath,
diff --git a/example/app_test.go b/example/app_test.go
index cfc7d2c..1432a67 100644
--- a/example/app_test.go
+++ b/example/app_test.go
@@ -313,8 +313,13 @@ func TestSandboxSubscribe(t *testing.T) {
want(t, "the account after paying", status, http.StatusOK, body,
"<strong>annual</strong>", `rst-tone="positive"`, say(abono.StatusRenews), `action="/account/billing"`)
- // The billing button opens the sandbox's portal.
- st, loc, _ := reader.post("/account/billing", nil)
+ // The billing button opens the sandbox's portal, for the customer it
+ // names.
+ m := regexp.MustCompile(`name="customer" value="([^"]+)"`).FindStringSubmatch(body)
+ if m == nil {
+ t.Fatalf("the billing button names no customer:\n%s", body)
+ }
+ st, loc, _ := reader.post("/account/billing", url.Values{"customer": {m[1]}})
if st != http.StatusSeeOther || !strings.HasPrefix(loc, app.URL+"/abono/sandbox/portal/") {
t.Errorf("billing: %d to %q", st, loc)
}
@@ -609,3 +614,42 @@ func TestDoneWhilePaymentIsProcessing(t *testing.T) {
}
}
}
+
+// TestBillingStaysAfterSwitchingToAnUnknownPrice is final review I5
+// (ruling R37): a reader switches, in Stripe's portal, to a price no plan
+// has. They hold nothing that grants, but they are still charged, so the
+// account page still has the billing button, for that subscription's
+// customer, and it opens the portal. A customer that is not theirs does
+// not.
+func TestBillingStaysAfterSwitchingToAnUnknownPrice(t *testing.T) {
+ stripe := abonotest.New(t)
+ stripe.AddPrice("price_annual", "annual", 6000, "eur", "year")
+ stripe.AddPrice("price_monthly", "monthly", 600, "eur", "month")
+ stripe.AddPrice("price_foreign", "", 900, "eur", "month")
+ app := newApp(t, func(c *Config) { c.Abono = stripe.Config })
+ stripe.SetWebhookURL(app.URL + abono.WebhookPath)
+ reader := newClient(t, app)
+ reader.signin("reader@example.com")
+ status, loc, body := reader.post("/subscribe/annual", nil)
+ if status != http.StatusSeeOther {
+ t.Fatalf("POST /subscribe/annual: %d to %q\n%s", status, loc, body)
+ }
+ reader.get(stripe.Pay(t, loc, ""))
+
+ now := stripe.Now()
+ stripe.AddSubscription(abonotest.Subscription{ID: "sub_1", Customer: "cus_1", Email: "reader@example.com",
+ Status: "active", Price: "price_foreign", PeriodStart: now, PeriodEnd: now.Add(30 * 24 * time.Hour)})
+ stripe.SendEvent("customer.subscription.updated", "sub_1")
+
+ status, body = reader.get("/account")
+ want(t, "the account on an unknown price", status, http.StatusOK, body,
+ say("abono.pages.account_none"), `action="/account/billing"`, `name="customer" value="cus_1"`)
+ st, loc, _ := reader.post("/account/billing", url.Values{"customer": {"cus_1"}})
+ if st != http.StatusSeeOther || !strings.HasPrefix(loc, stripe.URL+"/portal/cus_1") {
+ t.Errorf("billing for cus_1: %d to %q", st, loc)
+ }
+ st, loc, _ = reader.post("/account/billing", url.Values{"customer": {"cus_2"}})
+ if st != http.StatusSeeOther || loc != "/account?billing=none" {
+ t.Errorf("billing for a customer not the reader's: %d to %q", st, loc)
+ }
+}
diff --git a/example/templates/abono/account.html b/example/templates/abono/account.html
index 556ca9f..fe34f0d 100644
--- a/example/templates/abono/account.html
+++ b/example/templates/abono/account.html
@@ -1,6 +1,6 @@
{{/* abono-account: the membership section of your own account page, not
- a page of its own. Each holding as a plan and a status line, the
- billing button for a reader who pays through Stripe, a link to the
+ a page of its own. Each holding as a plan and a status line, a
+ billing button for each Stripe customer the reader pays through, a link to the
plans for one who holds nothing that grants, and the problem the
billing button came back with.
@@ -11,6 +11,7 @@
Holdings []abono.Holding, newest first
Now time.Time, the moment they are described at
Manage bool, there is billing to manage
+ Customers []string, the reader's Stripe customers, one button each
Granted bool, something grants access now
BillingAction string, where the billing button posts
PlansURL string, the pricing page
@@ -25,8 +26,8 @@
{{else}}<p>{{T $L "abono.pages.account_none"}}</p>
{{end}}
{{if .Manage}}<p>{{T $L "abono.pages.account_manage_help"}}</p>
-{{template "abono-billing-button" dict "L" $L "Action" .BillingAction}}
-{{end}}
+{{range .Customers}}{{template "abono-billing-button" dict "L" $L "Action" $.BillingAction "Customer" .}}
+{{end}}{{end}}
{{if not .Granted}}<p><a rst-btn="primary" href="{{.PlansURL}}">{{T $L "abono.button.see_plans"}}</a></p>
{{end}}
</section>{{end}}
diff --git a/sandbox_test.go b/sandbox_test.go
index 3ef56d2..8f98428 100644
--- a/sandbox_test.go
+++ b/sandbox_test.go
@@ -147,7 +147,7 @@ func TestSandboxRunsTheSameFlowWithoutStripe(t *testing.T) {
t.Errorf("Finish = %+v, %v", f, err)
}
- pu, err := s.Portal(ctx, "ana", "/account")
+ pu, err := s.Portal(ctx, "ana", onlyCustomer(t, s, "ana"), "/account")
if err != nil {
t.Fatalf("Portal: %v", err)
}
@@ -298,7 +298,7 @@ func TestSandboxRetriesADeliveryThatFailed(t *testing.T) {
if len(hs) != 1 {
t.Fatalf("Holdings = %+v", hs)
}
- pu, err := s.Portal(ctx, "ana", "/account")
+ pu, err := s.Portal(ctx, "ana", onlyCustomer(t, s, "ana"), "/account")
if err != nil {
t.Fatalf("Portal: %v", err)
}
@@ -321,3 +321,13 @@ func TestSandboxRetriesADeliveryThatFailed(t *testing.T) {
t.Errorf("after the retry: %+v, want one subscription, cancelling", hs)
}
}
+
+// onlyCustomer is subject's one Stripe customer.
+func onlyCustomer(t *testing.T, s *abono.Service, subject string) string {
+ t.Helper()
+ cs, err := s.Customers(context.Background(), subject)
+ if err != nil || len(cs) != 1 {
+ t.Fatalf("Customers(%s) = %q, %v; want one", subject, cs, err)
+ }
+ return cs[0]
+}
diff --git a/templates.go b/templates.go
index 705d495..7c71949 100644
--- a/templates.go
+++ b/templates.go
@@ -22,7 +22,7 @@ var templatesFS embed.FS
// abono-plan-picker {Offers, Action}: the plans on sale, by interval
// abono-paywall {Offers, SignedIn, SigninURL, Action, PlansURL}
// abono-subscribe-button {Action, Plan, Interval, Label, Code}: a POST form
-// abono-billing-button {Action}: a POST form
+// abono-billing-button {Action, Customer}: a POST form, one per customer
//
// Each finds the request's localizer under "L" in its data, as ui's
// partials do; add it with withL when calling one from another template.
diff --git a/templates/abono-billing-button.html b/templates/abono-billing-button.html
index cc5bf16..03b3791 100644
--- a/templates/abono-billing-button.html
+++ b/templates/abono-billing-button.html
@@ -2,7 +2,12 @@
portal, where a subscriber changes their card or cancels. A POST
because the handler mints a portal session.
+ A reader may pay through more than one Stripe customer (see
+ Service.Customers): render one button per customer, each posting its
+ id as "customer", for Service.Portal.
+
Keys:
- Action string, required — where the form posts
- L *ui.Localizer */}}
-{{define "abono-billing-button"}}{{$L := localizer $}}<form rst-form-actions method="post" action="{{.Action}}"><button type="submit" rst-btn>{{T $L "abono.button.manage"}}</button></form>{{end}}
+ Action string, required — where the form posts
+ Customer string, optional — the Stripe customer, posted as "customer"
+ L *ui.Localizer */}}
+{{define "abono-billing-button"}}{{$L := localizer $}}<form rst-form-actions method="post" action="{{.Action}}">{{with opt . "Customer"}}<input type="hidden" name="customer" value="{{.}}">{{end}}<button type="submit" rst-btn>{{T $L "abono.button.manage"}}</button></form>{{end}}
diff --git a/templates_test.go b/templates_test.go
index 674f2ad..fda5fd1 100644
--- a/templates_test.go
+++ b/templates_test.go
@@ -499,6 +499,8 @@ func TestButtonsArePostForms(t *testing.T) {
{"abono-subscribe-button", map[string]any{"L": l, "Action": "/subscribe", "Plan": "supporter"}, "/subscribe",
[]string{"T-SUBSCRIBE", `name="plan" value="supporter"`}},
{"abono-billing-button", map[string]any{"L": l, "Action": "/billing"}, "/billing", []string{"T-MANAGE"}},
+ {"abono-billing-button", map[string]any{"L": l, "Action": "/billing", "Customer": "cus_1"}, "/billing",
+ []string{"T-MANAGE", `<input type="hidden" name="customer" value="cus_1">`}},
} {
got := strings.TrimSpace(render(t, c.name, c.data))
m := form.FindStringSubmatch(got)