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.
12 files changed,
+207
−58
abonotest/abonotest_test.go+13 −3checkout.go+29 −6checkout_test.go+52 −15cmd/abono/pages/abono_pages.go.tmpl+17 −9cmd/abono/pages/account.html.tmpl+5 −4example/abono_pages.go+17 −9example/app_test.go+46 −2example/templates/abono/account.html+5 −4sandbox_test.go+12 −2templates.go+1 −1templates/abono-billing-button.html+8 −3templates_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
Intent
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) |