rastrillo / abono Public

abono: a Stripe double for tests and a sandbox for development

Summary

internal/fakestripe is an in-memory Stripe account: the API calls stripeapi makes (2025-03-31.basil shapes, period end on items), Checkout and Portal stand-in pages whose Pay, Decline and Cancel are POST forms, and signed events on every change; ids from counters, time from Options.Now plus Advance, renewals, FailNextRenewal and period-end cancellation on Tick. abonotest serves it on httptest for apps' tests, with Config, Pay, Decline, Cancel, AddSubscription and SendEvent. Config.Sandbox now runs the fake in-process: On() is true, Checkout and Portal lead to pages under SandboxPath (SandboxHandler), and events go straight to the service through receive, split out of ServeWebhook. The per-test stripe double now wraps fakestripe; the existing tests' assertions are unchanged except the Portal URL literal and R3's sandbox On().
Agent notes
Pages render message keys only (R20) through fakestripe.Lookup; abono's lookup reads a catalog that is empty until Task 8, so keys show. A signed-out buyer in the sandbox gets buyer_N@example.com because no reviewed key exists for an email field. The sandbox answers any price id, guessing the interval from the id. Sandbox ids carry a per-run tag so a restart does not reissue sub_1/evt_1 against the persisted tables. The fake moves the period on when a renewal fails, as Stripe does, which makes abono's past_due backstop count from the unpaid period's end: see the task report.
claude agent, operated by paul@keymail.dev a2a9907a082aab1b04ca699f5e4aca9617f6ba61 parent 685ec1e
15 files changed, +2664 −193
  • abono.go +16 −9
  • abono_test.go +2 −2
  • abonotest/abonotest.go +159 −0
  • abonotest/abonotest_test.go +503 −0
  • checkout_test.go +4 −5
  • internal/fakestripe/fake.go +861 −0
  • internal/fakestripe/fake_test.go +435 −0
  • internal/fakestripe/pages.go +259 −0
  • reconcile_test.go +1 −1
  • sandbox.go +104 −0
  • sandbox_test.go +243 −0
  • stripe_double_test.go +44 −151
  • sync_test.go +1 −1
  • webhook.go +27 −20
  • webhook_test.go +5 −4

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./... -count=1
ok  	amadan.net/rastrillo/abono	0.872s
ok  	amadan.net/rastrillo/abono/abonotest	0.049s
ok  	amadan.net/rastrillo/abono/internal/fakestripe	0.015s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	0.006s
=== race ===
CGO_ENABLED=1 go test ./... -race -count=1
ok  	amadan.net/rastrillo/abono	6.794s
ok  	amadan.net/rastrillo/abono/abonotest	1.498s
ok  	amadan.net/rastrillo/abono/internal/fakestripe	1.065s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	1.029s
=== browser ===
browser: no browser test file yet, nothing to run
diff --git a/abono.go b/abono.go
index a9d13f2..663b1be 100644
--- a/abono.go
+++ b/abono.go
@@ -11,6 +11,7 @@ import (
"gorm.io/gorm"
+ "amadan.net/rastrillo/abono/internal/fakestripe"
"amadan.net/rastrillo/abono/internal/stripeapi"
)
@@ -58,10 +59,12 @@ type Config struct {
// every subject holding anything, so it must be idempotent. Optional.
OnChange ChangeFunc
- // Sandbox runs against a local fake of Stripe instead of the real one.
- // It is only allowed on a loopback Origin and with no Key, so a
- // development setup cannot be pointed at a live account. The fake
- // itself is wired later; until then a sandbox service is not On.
+ // Sandbox runs against a fake of Stripe inside the app instead of the
+ // real one: Checkout and Portal lead to stand-in pages under
+ // SandboxPath (mount SandboxHandler there) that pay, fail or cancel on
+ // a click, and nothing leaves the process. It is only allowed on a
+ // loopback Origin and with no Key, so a development setup cannot be
+ // pointed at a live account. A sandbox service is On.
Sandbox bool
// Now is the clock; default time.Now. Tests set it.
@@ -76,8 +79,9 @@ type Config struct {
// Service is abono, wired. Build one per process with New and share it: it
// holds no per-request state.
type Service struct {
- cfg Config
- stripe *stripeapi.Client
+ cfg Config
+ stripe *stripeapi.Client
+ sandbox *fakestripe.Fake // set with Config.Sandbox
}
// New validates cfg and returns a ready *Service.
@@ -125,14 +129,17 @@ func New(cfg Config) (*Service, error) {
}
s := &Service{cfg: cfg}
- if cfg.Key != "" {
+ switch {
+ case cfg.Key != "":
s.stripe = &stripeapi.Client{Key: cfg.Key, Base: cfg.StripeBase}
+ case cfg.Sandbox:
+ s.startSandbox()
}
return s, nil
}
-// On reports whether Stripe is configured. When it is not, nothing is for
-// sale and the starter pages say so. Offers, comps and holdings work either
+// On reports whether Stripe is configured, or the sandbox stands in for
+// it. When it is not, nothing is for sale and the starter pages say so. Offers, comps and holdings work either
// way: a comp needs no Stripe.
func (s *Service) On() bool {
return s != nil && s.stripe != nil
diff --git a/abono_test.go b/abono_test.go
index 521bce9..cfd9d5d 100644
--- a/abono_test.go
+++ b/abono_test.go
@@ -141,8 +141,8 @@ func TestNewRefusesSandboxOffLoopback(t *testing.T) {
t.Errorf("Sandbox with Origin %q: %v", origin, err)
continue
}
- if s.On() {
- t.Errorf("Sandbox with Origin %q: On() is true; the sandbox is wired in a later task", origin)
+ if !s.On() {
+ t.Errorf("Sandbox with Origin %q: On() is false, want the sandbox's fake Stripe", origin)
}
}
if _, err := abono.New(abono.Config{DB: d.G, Sandbox: true, Origin: "http://localhost:8080", Key: "rk_test_1"}); err == nil {
diff --git a/abonotest/abonotest.go b/abonotest/abonotest.go
new file mode 100644
index 0000000..cf9cc3a
--- /dev/null
+++ b/abonotest/abonotest.go
@@ -0,0 +1,159 @@
+// Package abonotest is a fake Stripe for an app's tests to run abono
+// against end to end: the API abono calls, Checkout and Customer Portal
+// pages that pay, fail or cancel on a POST, and signed webhook events,
+// all on an httptest server on 127.0.0.1.
+//
+// stripe := abonotest.New(t)
+// stripe.AddPrice("price_annual", "annual", 6000, "eur", "year")
+// svc, _ := abono.New(stripe.Config(abono.Config{DB: db, Origin: app.URL, ...}))
+// stripe.SetWebhookURL(app.URL + abono.WebhookPath)
+//
+// u, _ := svc.Checkout(ctx, abono.CheckoutRequest{Plan: "annual"})
+// stripe.Pay(t, u, "ana@example.com") // the webhook has run when it returns
+//
+// It is deterministic: ids come from counters (sub_1, cus_1, cs_1) and the
+// clock is time.Now plus whatever Advance added. Config hands the service
+// the same clock, so a period that ends for the fake ends for abono too.
+package abonotest
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/abono"
+ "amadan.net/rastrillo/abono/internal/fakestripe"
+)
+
+// Subscription is a subscription as a test puts it in Stripe: an imported
+// member's, or one whose status Stripe changed. An empty ID or Customer
+// gets the next from the counter; Email and Name are the customer's.
+type Subscription = fakestripe.Subscription
+
+// Stripe is one fake Stripe account, served for the length of a test.
+type Stripe struct {
+ // URL is the fake's address: the API's base and the pages'.
+ URL string
+ // Secret signs the events it sends.
+ Secret string
+
+ fake *fakestripe.Fake
+}
+
+// New serves a fake Stripe until t ends. When t ends, any event that could
+// not be delivered fails it: an app whose webhook refuses abonotest's
+// events is a broken test.
+func New(t testing.TB) *Stripe {
+ t.Helper()
+ srv := httptest.NewUnstartedServer(nil)
+ s := &Stripe{URL: "http://" + srv.Listener.Addr().String(), Secret: "whsec_abonotest"}
+ s.fake = fakestripe.New(fakestripe.Options{WebhookSecret: s.Secret, PageBase: s.URL})
+ srv.Config.Handler = s.fake.Handler()
+ srv.Start()
+ t.Cleanup(func() {
+ srv.Close()
+ for _, err := range s.fake.DeliveryErrors() {
+ t.Errorf("abonotest: %v", err)
+ }
+ })
+ return s
+}
+
+// Config is base pointed at this fake: Key, StripeBase and WebhookSecrets
+// are set, and Now too when base has none, so abono and the fake read the
+// same clock.
+func (s *Stripe) Config(base abono.Config) abono.Config {
+ base.Key = "rk_test_abonotest"
+ base.StripeBase = s.URL
+ base.WebhookSecrets = []string{s.Secret}
+ if base.Now == nil {
+ base.Now = s.fake.Now
+ }
+ return base
+}
+
+// SetWebhookURL is where events go, usually the app's address plus
+// abono.WebhookPath. Until it is set, events are dropped.
+func (s *Stripe) SetWebhookURL(u string) { s.fake.SetWebhookURL(u) }
+
+// Now is the fake's clock.
+func (s *Stripe) Now() time.Time { return s.fake.Now() }
+
+// AddPrice adds a recurring price (interval "" for a one-off), named plan
+// on the Checkout page. Add every price the app's plans name, sold or
+// legacy: abono reads them.
+func (s *Stripe) AddPrice(id, plan string, amount int64, currency, interval string) {
+ s.fake.AddPrice(id, plan, amount, currency, interval)
+}
+
+// AddPromotionCode makes code a live promotion code with id.
+func (s *Stripe) AddPromotionCode(code, id string) { s.fake.AddPromotionCode(code, id) }
+
+// AddSubscription puts sub in the account as it is, or replaces the one
+// with its id, and returns its id. No event is sent: call SendEvent for
+// one, or let Reconcile find it.
+func (s *Stripe) AddSubscription(sub Subscription) string { return s.fake.PutSubscription(sub) }
+
+// SendEvent sends an event of typ (customer.subscription.updated, say)
+// about subscription id as it is now.
+func (s *Stripe) SendEvent(typ, id string) { s.fake.SendEvent(typ, id) }
+
+// Advance moves the clock on by d and does what Stripe would have done
+// meanwhile, sending its events: subscriptions renew, fail (see
+// FailNextRenewal) or end at their period end.
+func (s *Stripe) Advance(d time.Duration) { s.fake.Advance(d) }
+
+// FailNextRenewal makes subscription id's next renewal fail: it goes
+// past_due.
+func (s *Stripe) FailNextRenewal(id string) { s.fake.FailNextRenewal(id) }
+
+// Pay pays at the Checkout page checkoutURL (what abono's Checkout
+// returned) as email, or as the address Checkout was given when email is
+// "". It returns the success URL Checkout sends the buyer to. The
+// subscription's events have been delivered by the time it returns.
+func (s *Stripe) Pay(t testing.TB, checkoutURL, email string) string {
+ t.Helper()
+ form := url.Values{}
+ if email != "" {
+ form.Set("email", email)
+ }
+ return s.post(t, checkoutURL+"/pay", form)
+}
+
+// Decline fails the payment at the Checkout page checkoutURL, and returns
+// the cancel URL Checkout sends the buyer back to.
+func (s *Stripe) Decline(t testing.TB, checkoutURL string) string {
+ t.Helper()
+ return s.post(t, checkoutURL+"/decline", nil)
+}
+
+// Cancel cancels subscription id at the end of its period, at the Portal
+// page portalURL (what abono's Portal returned).
+func (s *Stripe) Cancel(t testing.TB, portalURL, id string) {
+ t.Helper()
+ s.post(t, portalURL+"/cancel", url.Values{"subscription": {id}})
+}
+
+// post sends form to one of the pages' actions, which must be one of
+// this fake's, and returns where it redirects.
+func (s *Stripe) post(t testing.TB, u string, form url.Values) string {
+ t.Helper()
+ if !strings.HasPrefix(u, s.URL+"/") {
+ t.Fatalf("abonotest: %q is not one of this fake's pages (%s)", u, s.URL)
+ }
+ c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ resp, err := c.PostForm(u, form)
+ if err != nil {
+ t.Fatalf("abonotest: POST %s: %v", u, err)
+ }
+ defer resp.Body.Close()
+ _, _ = io.Copy(io.Discard, resp.Body)
+ if resp.StatusCode != http.StatusSeeOther {
+ t.Fatalf("abonotest: POST %s: %s, want 303", u, resp.Status)
+ }
+ return resp.Header.Get("Location")
+}
diff --git a/abonotest/abonotest_test.go b/abonotest/abonotest_test.go
new file mode 100644
index 0000000..27dead4
--- /dev/null
+++ b/abonotest/abonotest_test.go
@@ -0,0 +1,503 @@
+package abonotest_test
+
+import (
+ "bytes"
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "gorm.io/gorm"
+
+ "amadan.net/rastrillo/rastrillo/csrf"
+ "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/migrate"
+ "amadan.net/rastrillo/rastrillo/sessions"
+
+ "amadan.net/rastrillo/abono"
+ "amadan.net/rastrillo/abono/abonotest"
+)
+
+const day = 24 * time.Hour
+
+// roles is Patrix's role move, as an app's Holder and OnChange. Holder
+// makes an account (the subject is the address) for whoever pays.
+// OnChange is told a subject's holdings moved; a staff subject it
+// declines to move, so staff keep their role whatever they pay for.
+// Roles are worked out from HasPlan afterwards, as Patrix's settle did,
+// because OnChange runs inside abono's transaction and HasPlan does not.
+type roles struct {
+ mu sync.Mutex
+ accounts map[string]bool
+ staff map[string]bool
+ told []string
+ declined []string
+}
+
+func (r *roles) Holder(_ context.Context, _ *gorm.DB, email, _ string) (string, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.accounts[email] = true
+ return email, nil
+}
+
+func (r *roles) OnChange(_ context.Context, _ *gorm.DB, subject string) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.staff[subject] {
+ r.declined = append(r.declined, subject)
+ return nil
+ }
+ r.told = append(r.told, subject)
+ return nil
+}
+
+// take returns who OnChange moved and declined since the last take.
+func (r *roles) take() (told, declined []string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ told, declined = r.told, r.declined
+ r.told, r.declined = nil, nil
+ return told, declined
+}
+
+func (r *roles) hasAccount(email string) bool {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return r.accounts[email]
+}
+
+// app is an app wired to abono the way the example will be: the webhook
+// and the tick mounted behind csrf.Protect with both exempted.
+type app struct {
+ t *testing.T
+ stripe *abonotest.Stripe // nil with no Stripe
+ s *abono.Service
+ url string
+ roles *roles
+}
+
+const owner = "owner@example.com"
+
+// newApp is an app over a fresh database, selling annual (price_annual,
+// with the old site's price_pmpro_annual as legacy) and monthly
+// (price_monthly), through abonotest's Stripe when stripe is set.
+func newApp(t *testing.T, stripe bool) *app {
+ t.Helper()
+ d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), nil)
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+ if _, err := migrate.Apply(context.Background(), d, migrate.Merge(sessions.Schema, abono.Schema)); err != nil {
+ t.Fatalf("migrate.Apply: %v", err)
+ }
+
+ srv := httptest.NewUnstartedServer(nil)
+ origin := "http://" + srv.Listener.Addr().String()
+ r := &roles{accounts: map[string]bool{}, staff: map[string]bool{owner: true}}
+ a := &app{t: t, url: origin, roles: r}
+ cfg := abono.Config{
+ DB: d.G, Origin: origin, Holder: r.Holder, OnChange: r.OnChange,
+ Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)),
+ }
+ if stripe {
+ a.stripe = abonotest.New(t)
+ a.stripe.AddPrice("price_annual", "annual", 6000, "eur", "year")
+ a.stripe.AddPrice("price_pmpro_annual", "annual", 4000, "eur", "year")
+ a.stripe.AddPrice("price_monthly", "monthly", 600, "eur", "month")
+ cfg = a.stripe.Config(cfg)
+ }
+ if a.s, err = abono.New(cfg); err != nil {
+ t.Fatalf("abono.New: %v", err)
+ }
+ if err := a.s.SeedPlans(context.Background(), []abono.PlanSpec{
+ {Plan: "annual", Sold: "price_annual", Legacy: []string{"price_pmpro_annual"}},
+ {Plan: "monthly", Sold: "price_monthly"},
+ }); err != nil {
+ t.Fatalf("SeedPlans: %v", err)
+ }
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST "+abono.WebhookPath, a.s.ServeWebhook)
+ mux.HandleFunc("POST "+abono.SyncPath, a.s.ServeSync)
+ srv.Config.Handler = csrf.Protect(origin, csrf.Exempt(abono.WebhookPath, abono.SyncPath))(mux)
+ srv.Start()
+ t.Cleanup(srv.Close)
+ if a.stripe != nil {
+ a.stripe.SetWebhookURL(origin + abono.WebhookPath)
+ }
+ return a
+}
+
+func (a *app) now() time.Time {
+ if a.stripe != nil {
+ return a.stripe.Now()
+ }
+ return time.Now()
+}
+
+func (a *app) hasPlan(subject, plan string) bool {
+ a.t.Helper()
+ ok, err := a.s.HasPlan(context.Background(), subject, plan)
+ if err != nil {
+ a.t.Fatalf("HasPlan(%q, %q): %v", subject, plan, err)
+ }
+ return ok
+}
+
+// role is what Patrix would make subject: staff stay staff, anyone holding
+// a plan is a subscriber, anyone else a spectator.
+func (a *app) role(subject string) string {
+ a.t.Helper()
+ switch {
+ case a.roles.staff[subject]:
+ return "owner"
+ case a.hasPlan(subject, "annual") || a.hasPlan(subject, "monthly"):
+ return "subscriber"
+ }
+ return "spectator"
+}
+
+func (a *app) wantRoles(when string, want map[string]string) {
+ a.t.Helper()
+ for subject, role := range want {
+ if got := a.role(subject); got != role {
+ a.t.Errorf("%s: %s is %s, want %s", when, subject, got, role)
+ }
+ }
+}
+
+func (a *app) holdings(subject string) []abono.Holding {
+ a.t.Helper()
+ hs, err := a.s.Holdings(context.Background(), subject)
+ if err != nil {
+ a.t.Fatalf("Holdings(%q): %v", subject, err)
+ }
+ return hs
+}
+
+// webhook posts body to the app's webhook with signature.
+func (a *app) webhook(body []byte, signature string) int {
+ a.t.Helper()
+ req, _ := http.NewRequest(http.MethodPost, a.url+abono.WebhookPath, bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ if signature != "" {
+ req.Header.Set("Stripe-Signature", signature)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ a.t.Fatalf("POST webhook: %v", err)
+ }
+ resp.Body.Close()
+ return resp.StatusCode
+}
+
+func sign(secret string, body []byte, at time.Time) string {
+ mac := hmac.New(sha256.New, []byte(secret))
+ fmt.Fprintf(mac, "%d.", at.Unix())
+ mac.Write(body)
+ return fmt.Sprintf("t=%d,v1=%s", at.Unix(), hex.EncodeToString(mac.Sum(nil)))
+}
+
+func eq(t *testing.T, what string, got, want []string) {
+ t.Helper()
+ if len(got) == 0 && len(want) == 0 {
+ return
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("%s = %q, want %q", what, got, want)
+ }
+}
+
+// TestAbonotestEndToEnd is Farshad's six end-to-end tests from Patrix,
+// against abonotest's Stripe.
+func TestAbonotestEndToEnd(t *testing.T) {
+ ctx := context.Background()
+
+ t.Run("checkout to holder to cancel", func(t *testing.T) {
+ a := newApp(t, true)
+ const reader = "new@example.com"
+
+ u, err := a.s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", SuccessPath: "/subscribe/done", CancelPath: "/subscribe/annual"})
+ if err != nil {
+ t.Fatalf("Checkout: %v", err)
+ }
+ if !strings.HasPrefix(u, a.stripe.URL+"/checkout/") {
+ t.Fatalf("Checkout URL = %q, want the fake's page", u)
+ }
+
+ // Paying sends the webhook, which makes the account.
+ done := a.stripe.Pay(t, u, "New@Example.com")
+ if !a.roles.hasAccount(reader) {
+ t.Fatal("Holder made no account for the reader")
+ }
+ if !a.hasPlan(reader, "annual") {
+ t.Fatal("HasPlan is false after paying")
+ }
+ told, _ := a.roles.take()
+ eq(t, "OnChange after paying", told, []string{reader})
+ a.wantRoles("after paying", map[string]string{reader: "subscriber"})
+
+ // Checkout's return changes nothing more.
+ back, err := url.Parse(done)
+ if err != nil || !strings.HasPrefix(done, a.url+"/subscribe/done?") {
+ t.Fatalf("success URL = %q", done)
+ }
+ f, err := a.s.Finish(ctx, back.Query().Get("session_id"))
+ if err != nil {
+ t.Fatalf("Finish: %v", err)
+ }
+ if want := (abono.Finished{Email: reader, Subject: reader, Paid: true}); f != want {
+ t.Errorf("Finish = %+v, want %+v", f, want)
+ }
+ hs := a.holdings(reader)
+ if len(hs) != 1 {
+ t.Fatalf("holdings = %+v, want one", hs)
+ }
+ if told, _ := a.roles.take(); len(told) != 0 {
+ t.Errorf("OnChange after Finish: %q, want nobody", told)
+ }
+ if got := abono.Describe(hs[0], a.now()).Key; got != abono.StatusRenews {
+ t.Errorf("status = %s, want renews", got)
+ }
+
+ // They cancel in the portal: still theirs until the period ends.
+ pu, err := a.s.Portal(ctx, reader, "/account")
+ if err != nil {
+ t.Fatalf("Portal: %v", err)
+ }
+ a.stripe.Cancel(t, pu, hs[0].SubscriptionID)
+ hs = a.holdings(reader)
+ if got := abono.Describe(hs[0], a.now()).Key; got != abono.StatusCancelling {
+ t.Errorf("after cancelling, status = %s, want %s", got, abono.StatusCancelling)
+ }
+ a.wantRoles("cancelling", map[string]string{reader: "subscriber"})
+
+ // The period ends: Stripe ends it, and they are a spectator.
+ a.stripe.Advance(366 * day)
+ if a.hasPlan(reader, "annual") {
+ t.Error("HasPlan is true past the period end")
+ }
+ if hs = a.holdings(reader); hs[0].Status != "canceled" {
+ t.Errorf("status past the period end = %s, want canceled", hs[0].Status)
+ }
+ a.wantRoles("after it ended", map[string]string{reader: "spectator"})
+ if told, _ := a.roles.take(); len(told) == 0 || told[len(told)-1] != reader {
+ t.Errorf("OnChange was not told it ended: %q", told)
+ }
+ })
+
+ t.Run("refused signatures", func(t *testing.T) {
+ a := newApp(t, true)
+ body := []byte(`{"id":"evt_x","type":"customer.subscription.updated","data":{"object":{"id":"sub_x"}}}`)
+ now := a.now()
+ for name, sig := range map[string]string{
+ "wrong secret": sign("whsec_wrong", body, now),
+ "stale": sign(a.stripe.Secret, body, now.Add(-time.Hour)),
+ "none": "",
+ } {
+ if code := a.webhook(body, sig); code != http.StatusBadRequest {
+ t.Errorf("%s: status %d, want 400", name, code)
+ }
+ }
+ // Signed, about a subscription Stripe does not have: taken, since
+ // a retry cannot bring it back (Patrix answered 500 here).
+ if code := a.webhook(body, sign(a.stripe.Secret, body, now)); code != http.StatusOK {
+ t.Errorf("signed, unknown subscription: status %d, want 200", code)
+ }
+ // An event abono does not read is taken and ignored.
+ other := []byte(`{"id":"evt_y","type":"customer.created","data":{"object":{"id":"cus_y"}}}`)
+ if code := a.webhook(other, sign(a.stripe.Secret, other, now)); code != http.StatusOK {
+ t.Errorf("an unread event: status %d, want 200", code)
+ }
+ // The tick is the platform's alone.
+ resp, err := http.Post(a.url+abono.SyncPath, "", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusForbidden {
+ t.Errorf("the tick without carlos's token: status %d, want 403", resp.StatusCode)
+ }
+ if told, declined := a.roles.take(); len(told)+len(declined) != 0 {
+ t.Errorf("OnChange ran: %q %q", told, declined)
+ }
+ })
+
+ t.Run("no Stripe", func(t *testing.T) {
+ a := newApp(t, false)
+ if a.s.On() {
+ t.Fatal("On() with nothing configured")
+ }
+ 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 {
+ t.Error("Portal succeeded with no Stripe")
+ }
+ body := []byte(`{"id":"evt_1","type":"customer.subscription.updated","data":{"object":{"id":"sub_1"}}}`)
+ if code := a.webhook(body, sign("whsec_any", body, time.Now())); code != http.StatusNotFound {
+ t.Errorf("webhook with no Stripe: status %d, want 404", code)
+ }
+ })
+
+ t.Run("staff untouched", func(t *testing.T) {
+ a := newApp(t, true)
+ u, err := a.s.Checkout(ctx, abono.CheckoutRequest{Plan: "monthly", Subject: owner, Email: owner})
+ if err != nil {
+ t.Fatalf("Checkout: %v", err)
+ }
+ a.stripe.Pay(t, u, "")
+ if !a.hasPlan(owner, "monthly") {
+ t.Error("the owner's own subscription does not hold")
+ }
+ told, declined := a.roles.take()
+ eq(t, "moved", told, nil)
+ eq(t, "declined", declined, []string{owner})
+ a.wantRoles("subscribed", map[string]string{owner: "owner"})
+
+ pu, err := a.s.Portal(ctx, owner, "/account")
+ if err != nil {
+ t.Fatalf("Portal: %v", err)
+ }
+ a.stripe.Cancel(t, pu, a.holdings(owner)[0].SubscriptionID)
+ a.stripe.Advance(32 * day)
+ if a.hasPlan(owner, "monthly") {
+ t.Error("the owner's ended subscription still holds")
+ }
+ told, declined = a.roles.take()
+ eq(t, "moved after it ended", told, nil)
+ if len(declined) == 0 {
+ t.Error("OnChange was not told the owner's subscription ended")
+ }
+ a.wantRoles("after it ended", map[string]string{owner: "owner"})
+
+ // A cancelled subscription nobody here holds makes no account.
+ a.stripe.AddSubscription(abonotest.Subscription{ID: "sub_gone", Email: "gone@example.com",
+ Price: "price_monthly", Status: "canceled", PeriodEnd: a.now()})
+ a.stripe.SendEvent("customer.subscription.deleted", "sub_gone")
+ if a.roles.hasAccount("gone@example.com") {
+ t.Error("a lapsed stranger got an account")
+ }
+ if hs := a.holdings("gone@example.com"); len(hs) != 0 {
+ t.Errorf("a lapsed stranger holds %+v", hs)
+ }
+ })
+
+ t.Run("import and reconcile", func(t *testing.T) {
+ a := newApp(t, true)
+ now := a.now()
+ const paid, comp, lapsing = "paid@example.com", "comp@example.com", "lapsing@example.com"
+
+ // The old site's members: one paying through Stripe on its own
+ // price, two given a membership, and the owner.
+ a.stripe.AddSubscription(abonotest.Subscription{ID: "sub_p", Customer: "cus_p", Email: paid, Name: "Paid Reader",
+ Price: "price_pmpro_annual", Status: "active", PeriodEnd: now.AddDate(0, 3, 0)})
+ if err := a.s.Adopt(ctx, "sub_p", "", "annual"); err != nil {
+ t.Fatalf("Adopt: %v", err)
+ }
+ compEnds, lapsingEnds := now.Add(70*day), now.Add(day)
+ for _, c := range []struct {
+ subject, plan string
+ ends *time.Time
+ }{{comp, "monthly", &compEnds}, {lapsing, "monthly", &lapsingEnds}, {owner, "annual", nil}} {
+ if _, err := a.s.Comp(ctx, c.subject, c.plan, c.ends, "imported"); err != nil {
+ t.Fatalf("Comp %s: %v", c.subject, err)
+ }
+ }
+ a.wantRoles("imported", map[string]string{paid: "subscriber", comp: "subscriber", lapsing: "subscriber", owner: "owner"})
+ a.roles.take()
+
+ // Again: nothing new.
+ if err := a.s.Adopt(ctx, "sub_p", "", "annual"); err != nil {
+ t.Fatalf("Adopt again: %v", err)
+ }
+ if hs := a.holdings(paid); len(hs) != 1 {
+ t.Errorf("after adopting twice, holdings = %+v", hs)
+ }
+ if told, _ := a.roles.take(); len(told) != 0 {
+ t.Errorf("adopting again told %q", told)
+ }
+
+ // Two days on, the reconcile reads Stripe and ends what ran out:
+ // the lapsing comp, and the paid one Stripe now says is unpaid.
+ a.stripe.Advance(2 * day)
+ a.stripe.AddSubscription(abonotest.Subscription{ID: "sub_p", Customer: "cus_p", Email: paid, Name: "Paid Reader",
+ Price: "price_pmpro_annual", Status: "unpaid", PeriodEnd: now.AddDate(0, 3, 0)})
+ rep, err := a.s.Reconcile(ctx)
+ if err != nil {
+ t.Fatalf("Reconcile: %v", err)
+ }
+ if rep.Subscriptions != 1 || rep.Subjects != 4 {
+ t.Errorf("Reconcile = %+v, want 1 subscription and 4 subjects", rep)
+ }
+ a.wantRoles("reconciled", map[string]string{paid: "spectator", comp: "subscriber", lapsing: "spectator", owner: "owner"})
+ hs := a.holdings(paid)
+ if len(hs) != 1 || hs[0].Plan != "annual" || hs[0].Status != "unpaid" {
+ t.Errorf("paid's holdings = %+v, want annual, unpaid", hs)
+ }
+ told, declined := a.roles.take()
+ // paid twice (its sync moved it, then the sweep), the other two
+ // members once, and the owner declined.
+ if len(told) != 4 || len(declined) != 1 {
+ t.Errorf("Reconcile told %q and declined %q, want paid twice, comp, lapsing, and the owner declined", told, declined)
+ }
+ })
+
+ t.Run("comps", func(t *testing.T) {
+ a := newApp(t, false) // a comp needs no Stripe
+ const friend = "friend@example.com"
+ ends := a.now().Add(90 * day)
+ id, err := a.s.Comp(ctx, friend, "annual", &ends, "a friend")
+ if err != nil {
+ t.Fatalf("Comp: %v", err)
+ }
+ a.wantRoles("given", map[string]string{friend: "subscriber"})
+ if hs := a.holdings(friend); len(hs) != 1 || abono.Describe(hs[0], a.now()).Key != abono.StatusCompUntil {
+ t.Errorf("holdings = %+v, want one comp until a date", hs)
+ }
+ if err := a.s.EndComp(ctx, id); err != nil {
+ t.Fatalf("EndComp: %v", err)
+ }
+ a.wantRoles("ended", map[string]string{friend: "spectator"})
+ told, _ := a.roles.take()
+ eq(t, "OnChange", told, []string{friend, friend})
+ if err := a.s.EndComp(ctx, id); err != nil {
+ t.Errorf("ending it again: %v", err)
+ }
+ if told, _ := a.roles.take(); len(told) != 0 {
+ t.Errorf("ending it again told %q", told)
+ }
+ if err := a.s.EndComp(ctx, id+100); !errors.Is(err, abono.ErrNotFound) {
+ t.Errorf("ending a comp that does not exist: %v, want ErrNotFound", err)
+ }
+ })
+}
+
+// TestAbonotestDecline: a declined payment makes nothing.
+func TestAbonotestDecline(t *testing.T) {
+ a := newApp(t, true)
+ u, err := a.s.Checkout(context.Background(), abono.CheckoutRequest{Plan: "annual", CancelPath: "/subscribe/annual"})
+ if err != nil {
+ t.Fatalf("Checkout: %v", err)
+ }
+ if back := a.stripe.Decline(t, u); back != a.url+"/subscribe/annual" {
+ t.Errorf("Decline came back to %q", back)
+ }
+ if told, _ := a.roles.take(); len(told) != 0 {
+ t.Errorf("OnChange after declining: %q", told)
+ }
+}
diff --git a/checkout_test.go b/checkout_test.go
index 043b77e..09a15d3 100644
--- a/checkout_test.go
+++ b/checkout_test.go
@@ -13,7 +13,7 @@ import (
func TestCheckoutForm(t *testing.T) {
now := t0
d := newStripeDouble(t)
- d.promos["INNOVATE"] = "promo_1"
+ d.AddPromotionCode("INNOVATE", "promo_1")
s, db := d.service(t, &now, abono.Config{Origin: "https://site.example"})
seedPlans(t, s)
ctx := context.Background()
@@ -231,12 +231,11 @@ func TestPortalWithoutCustomer(t *testing.T) {
if err != nil {
t.Fatalf("Portal: %v", err)
}
- if u != "https://billing.stripe.test/cus_1" {
+ if u != d.URL+"/portal/cus_1" {
t.Errorf("Portal URL = %q", u)
}
- d.mu.Lock()
- got := d.portals[len(d.portals)-1]
- d.mu.Unlock()
+ 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)
}
diff --git a/internal/fakestripe/fake.go b/internal/fakestripe/fake.go
new file mode 100644
index 0000000..1aba31e
--- /dev/null
+++ b/internal/fakestripe/fake.go
@@ -0,0 +1,861 @@
+// Package fakestripe is a stand-in for the part of Stripe abono uses: the
+// API calls internal/stripeapi makes, hosted Checkout and Customer Portal
+// pages that pay, fail or cancel on a click, and the signed events those
+// send. abonotest serves it to tests over HTTP; the sandbox runs it inside
+// the app.
+//
+// It is deterministic: ids come from counters (sub_1, cus_1, cs_1, evt_1)
+// and time from Options.Now plus whatever Advance added. It speaks only
+// the API version abono pins, 2025-03-31.basil, in which a
+// subscription's period end is on its items.
+package fakestripe
+
+import (
+ "bytes"
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "amadan.net/rastrillo/abono/internal/stripeapi"
+)
+
+// Lookup is the pages' text: the string for a message key, with {name}
+// placeholders filled from alternating name/value args. The pages carry
+// no words of their own.
+type Lookup func(key string, args ...any) string
+
+// Options configures a Fake.
+type Options struct {
+ // WebhookURL is where events are POSTed, signed with WebhookSecret.
+ // With neither it nor Deliver set, events are made and dropped.
+ WebhookURL string
+ WebhookSecret string
+ // Deliver, when set, is given each event instead of WebhookURL: the
+ // sandbox hands them straight to its service.
+ Deliver func(ctx context.Context, event []byte) error
+ // Now is the clock; default time.Now. Advance adds to it.
+ Now func() time.Time
+ // PageBase is the address the pages are served at, with no trailing
+ // slash: the Checkout and Portal URLs the API returns start with it.
+ PageBase string
+ // Lookup renders the pages' message keys; nil shows the keys.
+ Lookup Lookup
+ // IDTag, when set, goes into every id the fake makes (sub_<tag>_1),
+ // so ids stay unique across runs that share a database: the
+ // sandbox's fake starts empty each time the app does, and abono's
+ // tables remember the last run's sub_1 and evt_1.
+ IDTag string
+ // AnyPrice answers a price id nobody added as a recurring price,
+ // with its interval guessed from the id (see guessInterval). The
+ // sandbox has no Stripe account to read prices from.
+ AnyPrice bool
+}
+
+// Subscription is a subscription as a test describes it. An empty ID or
+// Customer is given the next one from the counter. Email and Name are
+// the customer's.
+type Subscription struct {
+ ID, Customer, Email, Name string
+ Status string
+ Price string
+ // Interval is the price's when empty, and "month" for a price nobody
+ // added.
+ Interval string
+ PeriodEnd time.Time
+ CancelAtPeriodEnd bool
+ Metadata map[string]string
+}
+
+// Session is what a test sets of a Checkout session.
+type Session struct {
+ ID, Status, PaymentStatus, Subscription, ClientReferenceID, Email string
+}
+
+// Fake is one Stripe account in memory. Its methods are safe for
+// concurrent use.
+type Fake struct {
+ opts Options
+
+ mu sync.Mutex
+ offset time.Duration
+ counters map[string]int
+ prices map[string]*price
+ promos map[string]string // code -> promo_...
+ customers map[string]*customer
+ subs map[string]*subscription
+ order []string // subscription ids, oldest first
+ sessions map[string]*session
+ portals map[string]string // customer -> return_url
+ failNext map[string]bool
+
+ checkoutForms []url.Values
+ portalForms []url.Values
+ priceFetches map[string]int
+ onFetch func()
+ failures []error
+}
+
+type price struct {
+ id, plan, currency, interval string
+ amount int64
+}
+
+type customer struct{ id, email, name string }
+
+type subscription struct {
+ id, customer, status, price, interval string
+ periodStart, periodEnd time.Time
+ cancel bool
+ metadata map[string]string
+}
+
+type session struct {
+ Session
+ customer, customerEmail, price string
+ successURL, cancelURL string
+ metadata map[string]string
+}
+
+// New returns an empty account.
+func New(opts Options) *Fake {
+ if opts.Now == nil {
+ opts.Now = time.Now
+ }
+ opts.PageBase = strings.TrimRight(opts.PageBase, "/")
+ return &Fake{
+ opts: opts, counters: map[string]int{}, prices: map[string]*price{}, promos: map[string]string{},
+ customers: map[string]*customer{}, subs: map[string]*subscription{}, sessions: map[string]*session{},
+ portals: map[string]string{}, failNext: map[string]bool{}, priceFetches: map[string]int{},
+ }
+}
+
+// Now is the fake's clock: Options.Now plus every Advance.
+func (f *Fake) Now() time.Time {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.now()
+}
+
+func (f *Fake) now() time.Time { return f.opts.Now().Add(f.offset).UTC() }
+
+// SetWebhookURL changes where events go: a test's app is often listening
+// only after the fake is made.
+func (f *Fake) SetWebhookURL(u string) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.opts.WebhookURL = u
+}
+
+// AddPrice adds a recurring price (interval "" for a one-off), or
+// replaces one. plan is the name the Checkout page shows.
+func (f *Fake) AddPrice(id, plan string, amount int64, currency, interval string) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.prices[id] = &price{id: id, plan: plan, amount: amount, currency: currency, interval: interval}
+}
+
+// AddPromotionCode makes code a live promotion code with id.
+func (f *Fake) AddPromotionCode(code, id string) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.promos[code] = id
+}
+
+// PutSubscription stores s as it is, replacing any with its id, and
+// returns its id. It sends no event: SendEvent does, when a test wants
+// one.
+func (f *Fake) PutSubscription(s Subscription) string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if s.ID == "" {
+ s.ID = f.next("sub")
+ }
+ if s.Customer == "" {
+ s.Customer = f.next("cus")
+ }
+ c := f.customers[s.Customer]
+ if c == nil {
+ c = &customer{id: s.Customer}
+ f.customers[s.Customer] = c
+ }
+ c.email, c.name = s.Email, s.Name
+ if s.Interval == "" {
+ s.Interval = "month"
+ if p := f.prices[s.Price]; p != nil && p.interval != "" {
+ s.Interval = p.interval
+ }
+ }
+ if _, ok := f.subs[s.ID]; !ok {
+ f.order = append(f.order, s.ID)
+ }
+ f.subs[s.ID] = &subscription{
+ id: s.ID, customer: s.Customer, status: s.Status, price: s.Price, interval: s.Interval,
+ periodEnd: s.PeriodEnd, cancel: s.CancelAtPeriodEnd, metadata: s.Metadata,
+ }
+ return s.ID
+}
+
+// SubscriptionJSON is the subscription as the API returns it, its
+// customer expanded.
+func (f *Fake) SubscriptionJSON(id string) ([]byte, bool) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ s := f.subs[id]
+ if s == nil {
+ return nil, false
+ }
+ b, err := json.Marshal(f.subObject(s, true))
+ return b, err == nil
+}
+
+// PutSession stores a Checkout session, replacing the test-visible part of
+// any with its id.
+func (f *Fake) PutSession(s Session) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if have := f.sessions[s.ID]; have != nil {
+ have.Session = s
+ return
+ }
+ f.sessions[s.ID] = &session{Session: s}
+}
+
+// Session is the test-visible part of a Checkout session.
+func (f *Fake) Session(id string) (Session, bool) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ s := f.sessions[id]
+ if s == nil {
+ return Session{}, false
+ }
+ return s.Session, true
+}
+
+// CheckoutForms is every Checkout session's form, in the order asked for.
+func (f *Fake) CheckoutForms() []url.Values {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]url.Values(nil), f.checkoutForms...)
+}
+
+// PortalForms is every Portal session's form, in the order asked for.
+func (f *Fake) PortalForms() []url.Values {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]url.Values(nil), f.portalForms...)
+}
+
+// PriceFetches is how many times each price has been read.
+func (f *Fake) PriceFetches() map[string]int {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ out := make(map[string]int, len(f.priceFetches))
+ for k, v := range f.priceFetches {
+ out[k] = v
+ }
+ return out
+}
+
+// OnSubscriptionFetch sets fn to run each time a subscription is read,
+// before the answer is sent.
+func (f *Fake) OnSubscriptionFetch(fn func()) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.onFetch = fn
+}
+
+// DeliveryErrors is every event that could not be delivered: the
+// endpoint was unreachable or did not answer 2xx.
+func (f *Fake) DeliveryErrors() []error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]error(nil), f.failures...)
+}
+
+// FailNextRenewal makes subID's next renewal fail: it goes past_due.
+func (f *Fake) FailNextRenewal(subID string) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.failNext[subID] = true
+}
+
+// Advance moves the clock on by d, then does what Stripe would have done
+// meanwhile (Tick).
+func (f *Fake) Advance(d time.Duration) {
+ f.mu.Lock()
+ f.offset += d
+ f.mu.Unlock()
+ f.Tick()
+}
+
+// Tick does what is due at the fake's now, and sends its events. A
+// subscription whose period has ended renews, paid, and moves to the next
+// period; with FailNextRenewal it goes past_due instead (the period still
+// moves on, as Stripe's does: the failed invoice is for the new one); set
+// to cancel at the period end, it ends. A past_due subscription is left
+// alone: Stripe's retries and what follows them are not modelled.
+func (f *Fake) Tick() {
+ f.mu.Lock()
+ now := f.now()
+ var events [][]byte
+ for _, id := range f.order {
+ s := f.subs[id]
+ for (s.status == "active" || s.status == "trialing") && !s.periodEnd.IsZero() && !now.Before(s.periodEnd) {
+ switch {
+ case s.cancel:
+ s.status = "canceled"
+ events = append(events, f.event("customer.subscription.deleted", f.subObject(s, false)))
+ case f.failNext[id]:
+ delete(f.failNext, id)
+ s.status = "past_due"
+ s.periodStart, s.periodEnd = s.periodEnd, addInterval(s.periodEnd, s.interval)
+ events = append(events, f.event("invoice.payment_failed", f.invoice(s, "open")),
+ f.event("customer.subscription.updated", f.subObject(s, false)))
+ default:
+ s.status = "active" // a trial that renews paid is over
+ s.periodStart, s.periodEnd = s.periodEnd, addInterval(s.periodEnd, s.interval)
+ events = append(events, f.event("invoice.paid", f.invoice(s, "paid")),
+ f.event("customer.subscription.updated", f.subObject(s, false)))
+ }
+ }
+ }
+ f.mu.Unlock()
+ f.deliver(context.Background(), events)
+}
+
+// SendEvent sends an event of typ about subscription subID as it is now,
+// as Stripe does when something changes it outside the fake's pages.
+func (f *Fake) SendEvent(typ, subID string) {
+ f.mu.Lock()
+ s := f.subs[subID]
+ if s == nil {
+ f.mu.Unlock()
+ return
+ }
+ e := f.event(typ, f.subObject(s, false))
+ f.mu.Unlock()
+ f.deliver(context.Background(), [][]byte{e})
+}
+
+// Handler serves the API (/v1/...) and the pages (/checkout/{id},
+// /portal/{customer}).
+func (f *Fake) Handler() http.Handler {
+ mux := http.NewServeMux()
+ api := func(pattern string, h http.HandlerFunc) { mux.Handle(pattern, f.authorised(h)) }
+ api("GET /v1/prices/{id}", f.getPrice)
+ api("GET /v1/subscriptions", f.listSubscriptions)
+ api("GET /v1/subscriptions/{id}", f.getSubscription)
+ api("POST /v1/checkout/sessions", f.createCheckout)
+ api("GET /v1/checkout/sessions/{id}", f.getCheckout)
+ api("POST /v1/billing_portal/sessions", f.createPortal)
+ api("GET /v1/promotion_codes", f.promotionCodes)
+ mux.HandleFunc("/v1/", func(w http.ResponseWriter, r *http.Request) {
+ f.refuse(w, http.StatusNotFound, "Unrecognized request URL: "+r.Method+" "+r.URL.Path)
+ })
+ f.pages(mux)
+ return mux
+}
+
+// authorised refuses what Stripe would: no key, or another API version.
+func (f *Fake) authorised(h http.HandlerFunc) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if key, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); !ok || strings.TrimSpace(key) == "" {
+ f.refuse(w, http.StatusUnauthorized, "You did not provide an API key.")
+ return
+ }
+ if v := r.Header.Get("Stripe-Version"); v != stripeapi.APIVersion {
+ f.refuse(w, http.StatusBadRequest, "This fake speaks only Stripe-Version "+stripeapi.APIVersion+", not "+strconv.Quote(v))
+ return
+ }
+ h(w, r)
+ })
+}
+
+func (f *Fake) getPrice(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ f.mu.Lock()
+ f.priceFetches[id]++
+ p := f.price(id)
+ f.mu.Unlock()
+ if p == nil {
+ f.missing(w, "price", id)
+ return
+ }
+ writeJSON(w, http.StatusOK, priceObject(p))
+}
+
+// price is the price with id, made up when AnyPrice allows. Hold f.mu.
+func (f *Fake) price(id string) *price {
+ if p := f.prices[id]; p != nil || !f.opts.AnyPrice || id == "" {
+ return p
+ }
+ p := &price{id: id, amount: 500, currency: "eur", interval: guessInterval(id)}
+ f.prices[id] = p
+ return p
+}
+
+// guessInterval is a made-up price's interval, read from its id:
+// price_annual is yearly, price_weekly weekly, anything else monthly.
+func guessInterval(id string) string {
+ s := strings.ToLower(id)
+ switch {
+ case strings.Contains(s, "year") || strings.Contains(s, "annual"):
+ return "year"
+ case strings.Contains(s, "week"):
+ return "week"
+ case strings.Contains(s, "day") || strings.Contains(s, "daily"):
+ return "day"
+ }
+ return "month"
+}
+
+func (f *Fake) listSubscriptions(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ limit, err := strconv.Atoi(q.Get("limit"))
+ if err != nil || limit < 1 || limit > 100 {
+ limit = 10
+ }
+ expand := hasExpand(q, "data.customer")
+ f.mu.Lock()
+ ids := f.order
+ if after := q.Get("starting_after"); after != "" {
+ for i, id := range ids {
+ if id == after {
+ ids = ids[i+1:]
+ break
+ }
+ }
+ }
+ more := len(ids) > limit
+ if more {
+ ids = ids[:limit]
+ }
+ data := make([]any, 0, len(ids))
+ for _, id := range ids {
+ data = append(data, f.subObject(f.subs[id], expand))
+ }
+ f.mu.Unlock()
+ writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": data, "has_more": more})
+}
+
+func (f *Fake) getSubscription(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ f.mu.Lock()
+ s, onFetch := f.subs[id], f.onFetch
+ var obj map[string]any
+ if s != nil {
+ obj = f.subObject(s, hasExpand(r.URL.Query(), "customer"))
+ }
+ f.mu.Unlock()
+ if onFetch != nil {
+ onFetch()
+ }
+ if s == nil {
+ f.missing(w, "subscription", id)
+ return
+ }
+ writeJSON(w, http.StatusOK, obj)
+}
+
+func (f *Fake) createCheckout(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ f.refuse(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ form := r.PostForm
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.checkoutForms = append(f.checkoutForms, form)
+ priceID := form.Get("line_items[0][price]")
+ switch {
+ case priceID == "":
+ f.refuse(w, http.StatusBadRequest, "Missing required param: line_items[0][price].")
+ return
+ case f.price(priceID) == nil:
+ f.missing(w, "price", priceID)
+ return
+ case form.Get("success_url") == "":
+ f.refuse(w, http.StatusBadRequest, "Missing required param: success_url.")
+ return
+ }
+ s := &session{
+ Session: Session{ID: f.next("cs"), Status: "open", PaymentStatus: "unpaid", ClientReferenceID: form.Get("client_reference_id")},
+ customer: form.Get("customer"), customerEmail: form.Get("customer_email"), price: priceID,
+ successURL: form.Get("success_url"), cancelURL: form.Get("cancel_url"), metadata: map[string]string{},
+ }
+ for k, vs := range form {
+ if name, ok := strings.CutPrefix(k, "subscription_data[metadata]["); ok && len(vs) > 0 {
+ s.metadata[strings.TrimSuffix(name, "]")] = vs[0]
+ }
+ }
+ f.sessions[s.ID] = s
+ writeJSON(w, http.StatusOK, f.sessionObject(s))
+}
+
+func (f *Fake) getCheckout(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ f.mu.Lock()
+ s := f.sessions[id]
+ var obj map[string]any
+ if s != nil {
+ obj = f.sessionObject(s)
+ }
+ f.mu.Unlock()
+ if s == nil {
+ f.missing(w, "checkout.session", id)
+ return
+ }
+ writeJSON(w, http.StatusOK, obj)
+}
+
+func (f *Fake) createPortal(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ f.refuse(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ customer := r.PostForm.Get("customer")
+ if customer == "" {
+ f.refuse(w, http.StatusBadRequest, "Missing required param: customer.")
+ return
+ }
+ f.mu.Lock()
+ f.portalForms = append(f.portalForms, r.PostForm)
+ f.portals[customer] = r.PostForm.Get("return_url")
+ id := f.next("bps")
+ f.mu.Unlock()
+ writeJSON(w, http.StatusOK, map[string]any{"id": id, "object": "billing_portal.session", "customer": customer,
+ "url": f.opts.PageBase + "/portal/" + url.PathEscape(customer)})
+}
+
+func (f *Fake) promotionCodes(w http.ResponseWriter, r *http.Request) {
+ code := r.URL.Query().Get("code")
+ f.mu.Lock()
+ var data []any
+ for c, id := range f.promos {
+ if strings.EqualFold(c, code) {
+ data = append(data, map[string]any{"id": id, "object": "promotion_code", "code": c, "active": true})
+ }
+ }
+ f.mu.Unlock()
+ if data == nil {
+ data = []any{}
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": data, "has_more": false})
+}
+
+// pay completes session id as a card payment that went through: a
+// customer (the one Checkout was given, else a new one with email), an
+// active subscription on the session's price for one period, and the
+// session complete and paid. It returns where Checkout sends the buyer.
+func (f *Fake) pay(ctx context.Context, id, email string) (string, error) {
+ f.mu.Lock()
+ s := f.sessions[id]
+ if err := usable(s); err != nil {
+ f.mu.Unlock()
+ return "", err
+ }
+ now := f.now()
+ c := f.customers[s.customer]
+ if c == nil {
+ if email == "" {
+ email = s.customerEmail
+ }
+ if email == "" {
+ email = f.next("buyer") + "@example.com"
+ }
+ c = &customer{id: s.customer, email: email}
+ if c.id == "" {
+ c.id = f.next("cus")
+ }
+ f.customers[c.id] = c
+ }
+ interval := "month"
+ if p := f.price(s.price); p != nil && p.interval != "" {
+ interval = p.interval
+ }
+ sub := &subscription{
+ id: f.next("sub"), customer: c.id, status: "active", price: s.price, interval: interval,
+ periodStart: now, periodEnd: addInterval(now, interval), metadata: s.metadata,
+ }
+ f.subs[sub.id] = sub
+ f.order = append(f.order, sub.id)
+ s.customer = c.id
+ s.Status, s.PaymentStatus, s.Subscription, s.Email = "complete", "paid", sub.id, c.email
+ events := [][]byte{
+ f.event("customer.subscription.created", f.subObject(sub, false)),
+ f.event("checkout.session.completed", f.sessionObject(s)),
+ }
+ back := strings.ReplaceAll(s.successURL, "{CHECKOUT_SESSION_ID}", s.ID)
+ f.mu.Unlock()
+ f.deliver(ctx, events)
+ return back, nil
+}
+
+// decline ends session id with nothing bought, and returns where Checkout
+// sends the buyer: a payment that fails the sandbox's way.
+func (f *Fake) decline(ctx context.Context, id string) (string, error) {
+ f.mu.Lock()
+ s := f.sessions[id]
+ if err := usable(s); err != nil {
+ f.mu.Unlock()
+ return "", err
+ }
+ s.Status = "expired"
+ e := f.event("checkout.session.expired", f.sessionObject(s))
+ back := s.cancelURL
+ f.mu.Unlock()
+ f.deliver(ctx, [][]byte{e})
+ return back, nil
+}
+
+// cancel sets customer's subscription subID to end with its period.
+func (f *Fake) cancel(ctx context.Context, customer, subID string) error {
+ f.mu.Lock()
+ s := f.subs[subID]
+ if s == nil || s.customer != customer {
+ f.mu.Unlock()
+ return errUnknown
+ }
+ if s.cancel || !live(s.status) {
+ f.mu.Unlock()
+ return nil
+ }
+ s.cancel = true
+ e := f.event("customer.subscription.updated", f.subObject(s, false))
+ f.mu.Unlock()
+ f.deliver(ctx, [][]byte{e})
+ return nil
+}
+
+var (
+ errUnknown = errors.New("fakestripe: no such session or subscription")
+ errGone = errors.New("fakestripe: the session is over")
+)
+
+// usable is why session s cannot be paid or declined, or nil. Hold f.mu.
+func usable(s *session) error {
+ switch {
+ case s == nil:
+ return errUnknown
+ case s.Status != "open":
+ return errGone
+ }
+ return nil
+}
+
+// live reports whether a subscription in status can still be cancelled.
+func live(status string) bool {
+ return status == "active" || status == "trialing" || status == "past_due"
+}
+
+// next is the next id with prefix that is not already taken. Hold f.mu.
+func (f *Fake) next(prefix string) string {
+ for {
+ f.counters[prefix]++
+ id := prefix + "_" + strconv.Itoa(f.counters[prefix])
+ if f.opts.IDTag != "" {
+ id = prefix + "_" + f.opts.IDTag + "_" + strconv.Itoa(f.counters[prefix])
+ }
+ _, sub := f.subs[id]
+ _, cus := f.customers[id]
+ _, cs := f.sessions[id]
+ if !sub && !cus && !cs {
+ return id
+ }
+ }
+}
+
+// event is a whole event, ready to send. Hold f.mu.
+func (f *Fake) event(typ string, object map[string]any) []byte {
+ b, err := json.Marshal(map[string]any{
+ "id": f.next("evt"), "object": "event", "type": typ, "api_version": stripeapi.APIVersion,
+ "created": f.now().Unix(), "livemode": false, "data": map[string]any{"object": object},
+ })
+ if err != nil {
+ panic(err) // maps of strings and numbers always marshal
+ }
+ return b
+}
+
+// deliver sends events in order. Call it without f.mu: the receiver
+// usually calls the API back.
+func (f *Fake) deliver(ctx context.Context, events [][]byte) {
+ f.mu.Lock()
+ o := f.opts
+ now := f.now()
+ f.mu.Unlock()
+ for _, e := range events {
+ var err error
+ switch {
+ case o.Deliver != nil:
+ err = o.Deliver(ctx, e)
+ case o.WebhookURL != "":
+ err = post(ctx, o.WebhookURL, e, sign(o.WebhookSecret, e, now))
+ default:
+ continue
+ }
+ if err != nil {
+ f.mu.Lock()
+ f.failures = append(f.failures, fmt.Errorf("fakestripe: delivering %s: %w", e, err))
+ f.mu.Unlock()
+ }
+ }
+}
+
+func post(ctx context.Context, u string, body []byte, signature string) error {
+ req, err := http.NewRequestWithContext(context.WithoutCancel(ctx), http.MethodPost, u, bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json; charset=utf-8")
+ req.Header.Set("Stripe-Signature", signature)
+ resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
+ if err != nil {
+ return err
+ }
+ resp.Body.Close()
+ if resp.StatusCode/100 != 2 {
+ return fmt.Errorf("%s answered %s", u, resp.Status)
+ }
+ return nil
+}
+
+// sign is the Stripe-Signature header for body at now.
+func sign(secret string, body []byte, now time.Time) string {
+ ts := strconv.FormatInt(now.Unix(), 10)
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write([]byte(ts + "."))
+ mac.Write(body)
+ return "t=" + ts + ",v1=" + hex.EncodeToString(mac.Sum(nil))
+}
+
+// subObject is s as the API renders it in 2025-03-31.basil: the period on
+// the items, the customer an id unless expanded. Hold f.mu.
+func (f *Fake) subObject(s *subscription, expand bool) map[string]any {
+ var cust any = s.customer
+ if expand {
+ c := f.customers[s.customer]
+ if c == nil {
+ c = &customer{id: s.customer}
+ }
+ cust = map[string]any{"id": c.id, "object": "customer", "email": nullable(c.email), "name": nullable(c.name)}
+ }
+ var interval any
+ if s.interval != "" {
+ interval = map[string]any{"interval": s.interval, "interval_count": 1}
+ }
+ metadata := map[string]string{}
+ for k, v := range s.metadata {
+ metadata[k] = v
+ }
+ return map[string]any{
+ "id": s.id, "object": "subscription", "status": s.status, "cancel_at_period_end": s.cancel,
+ "customer": cust, "metadata": metadata,
+ "items": map[string]any{"object": "list", "data": []any{map[string]any{
+ "id": "si_" + strings.TrimPrefix(s.id, "sub_"), "object": "subscription_item",
+ "current_period_start": unix(s.periodStart), "current_period_end": unix(s.periodEnd),
+ "price": map[string]any{"id": s.price, "object": "price", "recurring": interval},
+ }}},
+ }
+}
+
+// sessionObject is s as the API renders it. Hold f.mu.
+func (f *Fake) sessionObject(s *session) map[string]any {
+ email := s.Email
+ if email == "" && s.Status == "complete" {
+ if c := f.customers[s.customer]; c != nil {
+ email = c.email
+ }
+ }
+ var details any
+ if email != "" {
+ details = map[string]any{"email": email}
+ }
+ return map[string]any{
+ "id": s.ID, "object": "checkout.session", "mode": "subscription",
+ "url": f.opts.PageBase + "/checkout/" + url.PathEscape(s.ID), "status": s.Status, "payment_status": s.PaymentStatus,
+ "client_reference_id": nullable(s.ClientReferenceID), "subscription": nullable(s.Subscription),
+ "customer": nullable(s.customer), "customer_details": details,
+ }
+}
+
+// invoice is the invoice for s's latest period. Hold f.mu.
+func (f *Fake) invoice(s *subscription, status string) map[string]any {
+ return map[string]any{
+ "id": f.next("in"), "object": "invoice", "status": status, "customer": s.customer,
+ "parent": map[string]any{"type": "subscription_details", "subscription_details": map[string]any{"subscription": s.id}},
+ }
+}
+
+func priceObject(p *price) map[string]any {
+ var recurring any
+ if p.interval != "" {
+ recurring = map[string]any{"interval": p.interval, "interval_count": 1}
+ }
+ typ := "recurring"
+ if recurring == nil {
+ typ = "one_time"
+ }
+ return map[string]any{"id": p.id, "object": "price", "unit_amount": p.amount, "currency": p.currency,
+ "recurring": recurring, "type": typ, "active": true}
+}
+
+func addInterval(t time.Time, interval string) time.Time {
+ switch interval {
+ case "year":
+ return t.AddDate(1, 0, 0)
+ case "week":
+ return t.AddDate(0, 0, 7)
+ case "day":
+ return t.AddDate(0, 0, 1)
+ }
+ return t.AddDate(0, 1, 0)
+}
+
+func unix(t time.Time) int64 {
+ if t.IsZero() {
+ return 0
+ }
+ return t.Unix()
+}
+
+func nullable(s string) any {
+ if s == "" {
+ return nil
+ }
+ return s
+}
+
+func hasExpand(q url.Values, field string) bool {
+ for _, e := range q["expand[]"] {
+ if e == field {
+ return true
+ }
+ }
+ return false
+}
+
+func (f *Fake) missing(w http.ResponseWriter, what, id string) {
+ writeJSON(w, http.StatusNotFound, map[string]any{"error": map[string]any{
+ "type": "invalid_request_error", "code": "resource_missing", "message": "No such " + what + ": '" + id + "'"}})
+}
+
+func (f *Fake) refuse(w http.ResponseWriter, status int, msg string) {
+ writeJSON(w, status, map[string]any{"error": map[string]any{"type": "invalid_request_error", "message": msg}})
+}
+
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
diff --git a/internal/fakestripe/fake_test.go b/internal/fakestripe/fake_test.go
new file mode 100644
index 0000000..c529189
--- /dev/null
+++ b/internal/fakestripe/fake_test.go
@@ -0,0 +1,435 @@
+package fakestripe_test
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "reflect"
+ "regexp"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/abono/internal/fakestripe"
+ "amadan.net/rastrillo/abono/internal/stripeapi"
+)
+
+// t0 is the fake's clock before any Advance.
+var t0 = time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)
+
+const secret = "whsec_fake"
+
+// delivery is one event the fake POSTed.
+type delivery struct {
+ signature string
+ body []byte
+}
+
+// hook is a webhook endpoint that keeps what it is sent.
+type hook struct {
+ mu sync.Mutex
+ got []delivery
+ code int // what it answers; 0 is 200
+}
+
+func (h *hook) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.got = append(h.got, delivery{r.Header.Get("Stripe-Signature"), body})
+ if h.code != 0 {
+ w.WriteHeader(h.code)
+ }
+}
+
+// take returns what arrived since the last take.
+func (h *hook) take() []delivery {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ got := h.got
+ h.got = nil
+ return got
+}
+
+// types is each delivery's event type, checking every signature on the
+// way.
+func types(t *testing.T, ds []delivery, now time.Time) []string {
+ t.Helper()
+ var out []string
+ for _, d := range ds {
+ if err := stripeapi.VerifySignature(d.signature, d.body, []string{secret}, now); err != nil {
+ t.Errorf("event %s: signature does not verify: %v", d.body, err)
+ }
+ if err := stripeapi.VerifySignature(d.signature, d.body, []string{"whsec_other"}, now); err == nil {
+ t.Errorf("event %s verifies with another secret", d.body)
+ }
+ var e struct {
+ Type string `json:"type"`
+ }
+ if err := json.Unmarshal(d.body, &e); err != nil || e.Type == "" {
+ t.Fatalf("not an event: %s", d.body)
+ }
+ out = append(out, e.Type)
+ }
+ return out
+}
+
+type rig struct {
+ f *fakestripe.Fake
+ base string
+ client *stripeapi.Client
+ hook *hook
+}
+
+// start serves a fake on 127.0.0.1 whose pages are at its own address,
+// sending its events to a hook.
+func start(t *testing.T) *rig {
+ t.Helper()
+ h := &hook{}
+ hooks := httptest.NewServer(h)
+ t.Cleanup(hooks.Close)
+
+ srv := httptest.NewUnstartedServer(nil)
+ base := "http://" + srv.Listener.Addr().String()
+ f := fakestripe.New(fakestripe.Options{
+ WebhookURL: hooks.URL, WebhookSecret: secret, PageBase: base,
+ Now: func() time.Time { return t0 },
+ })
+ srv.Config.Handler = f.Handler()
+ srv.Start()
+ t.Cleanup(srv.Close)
+ f.AddPrice("price_A", "annual", 6000, "eur", "year")
+ return &rig{f: f, base: base, client: &stripeapi.Client{Key: "rk_test_fake", Base: base}, hook: h}
+}
+
+// noRedirects is a client that reports a redirect rather than following
+// it.
+var noRedirects = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+
+func get(t *testing.T, u string) (int, string) {
+ t.Helper()
+ resp, err := noRedirects.Get(u)
+ if err != nil {
+ t.Fatalf("GET %s: %v", u, err)
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ return resp.StatusCode, string(body)
+}
+
+func post(t *testing.T, u string, form url.Values) (int, string) {
+ t.Helper()
+ resp, err := noRedirects.PostForm(u, form)
+ if err != nil {
+ t.Fatalf("POST %s: %v", u, err)
+ }
+ defer resp.Body.Close()
+ _, _ = io.Copy(io.Discard, resp.Body)
+ return resp.StatusCode, resp.Header.Get("Location")
+}
+
+// keysOnly checks that every piece of text on a page is a message key:
+// the pages carry no English of their own (ruling R20).
+func keysOnly(t *testing.T, page string, want ...string) {
+ t.Helper()
+ for _, m := range regexp.MustCompile(`>([^<]+)<`).FindAllStringSubmatch(page, -1) {
+ if s := strings.TrimSpace(m[1]); s != "" && !strings.HasPrefix(s, "abono.") {
+ t.Errorf("the page says %q, which is not a message key", s)
+ }
+ }
+ for _, key := range want {
+ if !strings.Contains(page, ">"+key+"<") {
+ t.Errorf("the page does not show %s:\n%s", key, page)
+ }
+ }
+}
+
+// checkout starts a Checkout for ana on price_A, as abono does.
+func (r *rig) checkout(t *testing.T) stripeapi.CheckoutSession {
+ t.Helper()
+ cs, err := r.client.CreateCheckout(context.Background(), url.Values{
+ "mode": {"subscription"},
+ "line_items[0][price]": {"price_A"},
+ "line_items[0][quantity]": {"1"},
+ "success_url": {"https://site.example/done?session_id={CHECKOUT_SESSION_ID}"},
+ "cancel_url": {"https://site.example/join"},
+ "client_reference_id": {"ana"},
+ "customer_email": {"ana@example.com"},
+ "subscription_data[metadata][abono_subject]": {"ana"},
+ })
+ if err != nil {
+ t.Fatalf("CreateCheckout: %v", err)
+ }
+ return cs
+}
+
+// subscribe checks out and pays, and returns the subscription's id.
+func (r *rig) subscribe(t *testing.T) string {
+ t.Helper()
+ cs := r.checkout(t)
+ if code, _ := post(t, cs.URL+"/pay", nil); code != http.StatusSeeOther {
+ t.Fatalf("Pay: status %d", code)
+ }
+ got, err := r.client.GetCheckout(context.Background(), cs.ID)
+ if err != nil {
+ t.Fatalf("GetCheckout: %v", err)
+ }
+ r.hook.take()
+ return got.Subscription
+}
+
+func (r *rig) sub(t *testing.T, id string) stripeapi.Subscription {
+ t.Helper()
+ s, err := r.client.GetSubscription(context.Background(), id)
+ if err != nil {
+ t.Fatalf("GetSubscription(%s): %v", id, err)
+ }
+ return s
+}
+
+func TestFakeCheckoutPayCreatesAnActiveSubscriptionAndSignsAnEvent(t *testing.T) {
+ r := start(t)
+ ctx := context.Background()
+
+ cs := r.checkout(t)
+ if cs.ID != "cs_1" || cs.URL != r.base+"/checkout/cs_1" || cs.Status != "open" || cs.PaymentStatus != "unpaid" {
+ t.Fatalf("session = %+v", cs)
+ }
+ code, page := get(t, cs.URL)
+ if code != http.StatusOK {
+ t.Fatalf("checkout page: status %d", code)
+ }
+ keysOnly(t, page, "abono.sandbox.banner", "abono.sandbox.checkout_title", "abono.sandbox.checkout_intro",
+ "abono.sandbox.checkout_item", "abono.sandbox.pay", "abono.sandbox.decline", "abono.sandbox.leave")
+ if !strings.Contains(page, `href="https://site.example/join"`) {
+ t.Errorf("the page does not lead back to the cancel URL:\n%s", page)
+ }
+ if len(r.hook.take()) != 0 {
+ t.Fatal("an event was sent before paying")
+ }
+
+ code, loc := post(t, cs.URL+"/pay", nil)
+ if code != http.StatusSeeOther || loc != "https://site.example/done?session_id=cs_1" {
+ t.Fatalf("Pay: %d to %q, want 303 to the success URL with the session id", code, loc)
+ }
+
+ sub := r.sub(t, "sub_1")
+ price, interval := sub.PriceID()
+ if sub.Status != "active" || sub.CancelAtPeriodEnd || sub.Customer.ID != "cus_1" || sub.Customer.Email != "ana@example.com" ||
+ price != "price_A" || interval != "year" || sub.Metadata["abono_subject"] != "ana" {
+ t.Errorf("subscription = %+v", sub)
+ }
+ // 2025-03-31.basil: the period end is on the items, not the subscription.
+ if sub.CurrentPeriodEnd != 0 || !sub.PeriodEnd().Equal(t0.AddDate(1, 0, 0)) {
+ t.Errorf("period end: top level %d, items %v; want only the items, a year on", sub.CurrentPeriodEnd, sub.PeriodEnd())
+ }
+ done, err := r.client.GetCheckout(ctx, "cs_1")
+ if err != nil {
+ t.Fatalf("GetCheckout: %v", err)
+ }
+ if done.Status != "complete" || done.PaymentStatus != "paid" || done.Subscription != "sub_1" ||
+ done.ClientReferenceID != "ana" || done.CustomerDetails.Email != "ana@example.com" {
+ t.Errorf("finished session = %+v", done)
+ }
+
+ if got, want := types(t, r.hook.take(), t0), []string{"customer.subscription.created", "checkout.session.completed"}; !reflect.DeepEqual(got, want) {
+ t.Errorf("events = %v, want %v", got, want)
+ }
+
+ // Paid is paid: the page and its buttons are gone.
+ if code, _ := post(t, cs.URL+"/pay", nil); code != http.StatusGone {
+ t.Errorf("paying twice: status %d, want 410", code)
+ }
+ code, page = get(t, cs.URL)
+ if code != http.StatusGone {
+ t.Errorf("a paid session's page: status %d, want 410", code)
+ }
+ keysOnly(t, page, "abono.sandbox.banner", "abono.sandbox.expired")
+ if len(r.hook.take()) != 0 {
+ t.Error("paying twice sent events")
+ }
+}
+
+func TestFakeCheckoutDeclineMakesNothing(t *testing.T) {
+ r := start(t)
+ cs := r.checkout(t)
+ code, loc := post(t, cs.URL+"/decline", nil)
+ if code != http.StatusSeeOther || loc != "https://site.example/join" {
+ t.Fatalf("Decline: %d to %q, want 303 to the cancel URL", code, loc)
+ }
+ got, err := r.client.GetCheckout(context.Background(), cs.ID)
+ if err != nil {
+ t.Fatalf("GetCheckout: %v", err)
+ }
+ if got.Status != "expired" || got.Subscription != "" {
+ t.Errorf("declined session = %+v", got)
+ }
+ if _, err := r.client.GetSubscription(context.Background(), "sub_1"); !stripeapi.NotFound(err) {
+ t.Errorf("a subscription after declining: %v", err)
+ }
+ if got, want := types(t, r.hook.take(), t0), []string{"checkout.session.expired"}; !reflect.DeepEqual(got, want) {
+ t.Errorf("events = %v, want %v", got, want)
+ }
+ if code, _ := get(t, r.base+"/checkout/cs_404"); code != http.StatusNotFound {
+ t.Errorf("an unknown session's page: status %d, want 404", code)
+ }
+}
+
+func TestFakeRenewalFailureGoesPastDue(t *testing.T) {
+ r := start(t)
+ id := r.subscribe(t)
+
+ // A renewal that is paid moves the period on.
+ r.f.Advance(366 * 24 * time.Hour)
+ now := t0.Add(366 * 24 * time.Hour)
+ sub := r.sub(t, id)
+ if sub.Status != "active" || !sub.PeriodEnd().Equal(t0.AddDate(2, 0, 0)) {
+ t.Errorf("after a paid renewal: %s until %v", sub.Status, sub.PeriodEnd())
+ }
+ if got, want := types(t, r.hook.take(), now), []string{"invoice.paid", "customer.subscription.updated"}; !reflect.DeepEqual(got, want) {
+ t.Errorf("renewal events = %v, want %v", got, want)
+ }
+
+ // The next one fails: past_due, and like Stripe the period still
+ // moves on, to the one the failed invoice was for.
+ r.f.FailNextRenewal(id)
+ r.f.Advance(366 * 24 * time.Hour)
+ now = now.Add(366 * 24 * time.Hour)
+ sub = r.sub(t, id)
+ if sub.Status != "past_due" || !sub.PeriodEnd().Equal(t0.AddDate(3, 0, 0)) {
+ t.Errorf("after a failed renewal: %s until %v, want past_due until %v", sub.Status, sub.PeriodEnd(), t0.AddDate(3, 0, 0))
+ }
+ if got, want := types(t, r.hook.take(), now), []string{"invoice.payment_failed", "customer.subscription.updated"}; !reflect.DeepEqual(got, want) {
+ t.Errorf("failure events = %v, want %v", got, want)
+ }
+
+ // Nothing more happens to a past_due subscription on its own.
+ r.f.Advance(366 * 24 * time.Hour)
+ if sub = r.sub(t, id); sub.Status != "past_due" {
+ t.Errorf("later: %s, want still past_due", sub.Status)
+ }
+ if got := r.hook.take(); len(got) != 0 {
+ t.Errorf("events for a past_due subscription: %d", len(got))
+ }
+}
+
+func TestFakePortalCancelSetsCancelAtPeriodEnd(t *testing.T) {
+ r := start(t)
+ ctx := context.Background()
+ id := r.subscribe(t)
+
+ u, err := r.client.PortalURL(ctx, "cus_1", "https://site.example/account")
+ if err != nil {
+ t.Fatalf("PortalURL: %v", err)
+ }
+ if u != r.base+"/portal/cus_1" {
+ t.Fatalf("portal URL = %q", u)
+ }
+ code, page := get(t, u)
+ if code != http.StatusOK {
+ t.Fatalf("portal page: status %d", code)
+ }
+ keysOnly(t, page, "abono.sandbox.banner", "abono.sandbox.portal_title", "abono.sandbox.portal_intro",
+ "abono.sandbox.portal_plan", "abono.sandbox.portal_cancel", "abono.sandbox.portal_return")
+ if !strings.Contains(page, `href="https://site.example/account"`) || !strings.Contains(page, `value="`+id+`"`) {
+ t.Errorf("the page lacks the return link or the subscription:\n%s", page)
+ }
+
+ code, loc := post(t, u+"/cancel", url.Values{"subscription": {id}})
+ if code != http.StatusSeeOther || loc != u {
+ t.Fatalf("Cancel: %d to %q, want 303 back to the portal", code, loc)
+ }
+ if sub := r.sub(t, id); sub.Status != "active" || !sub.CancelAtPeriodEnd {
+ t.Errorf("after cancelling: %s, cancel_at_period_end %v", sub.Status, sub.CancelAtPeriodEnd)
+ }
+ if got, want := types(t, r.hook.take(), t0), []string{"customer.subscription.updated"}; !reflect.DeepEqual(got, want) {
+ t.Errorf("events = %v, want %v", got, want)
+ }
+ if _, page = get(t, u); strings.Contains(page, "abono.sandbox.portal_cancel") {
+ t.Error("a cancelling subscription can still be cancelled")
+ }
+
+ // Somebody else's subscription is not this customer's to cancel.
+ if code, _ := post(t, r.base+"/portal/cus_2/cancel", url.Values{"subscription": {id}}); code != http.StatusNotFound {
+ t.Errorf("cancelling another customer's subscription: status %d, want 404", code)
+ }
+
+ // At the period end it ends.
+ r.f.Advance(366 * 24 * time.Hour)
+ if sub := r.sub(t, id); sub.Status != "canceled" {
+ t.Errorf("past the period end: %s, want canceled", sub.Status)
+ }
+ if got, want := types(t, r.hook.take(), t0.Add(366*24*time.Hour)), []string{"customer.subscription.deleted"}; !reflect.DeepEqual(got, want) {
+ t.Errorf("events = %v, want %v", got, want)
+ }
+}
+
+func TestFakeAPIIsStripes(t *testing.T) {
+ r := start(t)
+ ctx := context.Background()
+
+ // A key and the pinned version are required, as they are of abono.
+ req, _ := http.NewRequest(http.MethodGet, r.base+"/v1/prices/price_A", nil)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("no key: status %d, want 401", resp.StatusCode)
+ }
+ req.Header.Set("Authorization", "Bearer rk_test_fake")
+ req.Header.Set("Stripe-Version", "2020-08-27")
+ if resp, err = http.DefaultClient.Do(req); err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Errorf("another API version: status %d, want 400", resp.StatusCode)
+ }
+
+ p, err := r.client.GetPrice(ctx, "price_A")
+ if err != nil || p.UnitAmount != 6000 || p.Currency != "eur" || p.Recurring.Interval != "year" {
+ t.Errorf("GetPrice = %+v, %v", p, err)
+ }
+ if _, err := r.client.GetPrice(ctx, "price_nope"); !stripeapi.NotFound(err) {
+ t.Errorf("an unknown price: %v, want not found", err)
+ }
+ r.f.AddPromotionCode("INNOVATE", "promo_1")
+ if id, err := r.client.PromotionCode(ctx, "INNOVATE"); err != nil || id != "promo_1" {
+ t.Errorf("PromotionCode = %q, %v", id, err)
+ }
+ if id, err := r.client.PromotionCode(ctx, "NOPE"); err != nil || id != "" {
+ t.Errorf("an unknown code = %q, %v", id, err)
+ }
+
+ // Ids come from a counter, and the list holds every subscription.
+ for range 3 {
+ r.subscribe(t)
+ }
+ var ids []string
+ if err := r.client.ListSubscriptions(ctx, func(s stripeapi.Subscription) error {
+ ids = append(ids, s.ID+"/"+s.Customer.Email)
+ return nil
+ }); err != nil {
+ t.Fatalf("ListSubscriptions: %v", err)
+ }
+ if want := []string{"sub_1/ana@example.com", "sub_2/ana@example.com", "sub_3/ana@example.com"}; !reflect.DeepEqual(ids, want) {
+ t.Errorf("listed %v, want %v", ids, want)
+ }
+}
+
+func TestFakeRecordsAFailedDelivery(t *testing.T) {
+ r := start(t)
+ r.hook.mu.Lock()
+ r.hook.code = http.StatusInternalServerError
+ r.hook.mu.Unlock()
+ r.subscribe(t)
+ if errs := r.f.DeliveryErrors(); len(errs) != 2 {
+ t.Errorf("delivery errors = %v, want one for each of the two events", errs)
+ }
+}
diff --git a/internal/fakestripe/pages.go b/internal/fakestripe/pages.go
new file mode 100644
index 0000000..d1e9484
--- /dev/null
+++ b/internal/fakestripe/pages.go
@@ -0,0 +1,259 @@
+package fakestripe
+
+import (
+ "errors"
+ "fmt"
+ "html/template"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+)
+
+// The pages stand in for Stripe's hosted Checkout and Customer Portal.
+// They are plain forms in rastrillo's rst- vocabulary with no stylesheet
+// of their own, and every word on them is a message key given to
+// Options.Lookup (abono.sandbox.*): the copy is reviewed with the rest of
+// abono's. Pay, Decline and Cancel are POSTs; a browser sends them from
+// the page's own origin, so an app's csrf.Protect lets them through.
+
+var page = template.Must(template.New("page").Parse(`<!doctype html>
+<html>
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta name="robots" content="noindex">
+<title>{{.Title}}</title>
+</head>
+<body>
+<main rst-page>
+<div rst-callout rst-tone="warning"><div rst-callout-body><p>{{.Banner}}</p></div></div>
+{{- if .Expired}}
+<p>{{.Expired}}</p>
+{{- else}}
+<h1>{{.Title}}</h1>
+<p>{{.Intro}}</p>
+{{- with .Checkout}}
+<p>{{.Item}}</p>
+<form rst-form method="post" action="{{.PayAction}}">
+<button rst-btn="primary lg" type="submit">{{.Pay}}</button>
+</form>
+<form rst-form method="post" action="{{.DeclineAction}}">
+<button rst-btn="lg" type="submit">{{.Decline}}</button>
+</form>
+{{- if .Leave}}
+<p><a href="{{.LeaveHref}}">{{.Leave}}</a></p>
+{{- end}}
+{{- end}}
+{{- range .Portal}}
+<form rst-form method="post" action="{{.CancelAction}}">
+<p>{{.Plan}}</p>
+{{- if .Cancel}}
+<input type="hidden" name="subscription" value="{{.ID}}">
+<button rst-btn="lg" type="submit">{{.Cancel}}</button>
+{{- end}}
+</form>
+{{- end}}
+{{- if .Return}}
+<p><a href="{{.ReturnHref}}">{{.Return}}</a></p>
+{{- end}}
+{{- end}}
+</main>
+</body>
+</html>
+`))
+
+type pageData struct {
+ Title, Banner, Intro, Expired string
+ Checkout *checkoutData
+ Portal []portalItem
+ Return string
+ ReturnHref template.URL
+}
+
+type checkoutData struct {
+ Item, Pay, Decline, Leave string
+ PayAction, DeclineAction string
+ LeaveHref template.URL
+}
+
+type portalItem struct {
+ ID, Plan, Cancel, CancelAction string
+}
+
+func (f *Fake) pages(mux *http.ServeMux) {
+ mux.HandleFunc("GET /checkout/{id}", f.checkoutPage)
+ mux.HandleFunc("POST /checkout/{id}/pay", func(w http.ResponseWriter, r *http.Request) {
+ _ = r.ParseForm()
+ back, err := f.pay(r.Context(), r.PathValue("id"), strings.TrimSpace(r.PostForm.Get("email")))
+ f.redirect(w, r, back, err)
+ })
+ mux.HandleFunc("POST /checkout/{id}/decline", func(w http.ResponseWriter, r *http.Request) {
+ back, err := f.decline(r.Context(), r.PathValue("id"))
+ f.redirect(w, r, back, err)
+ })
+ mux.HandleFunc("GET /portal/{customer}", f.portalPage)
+ mux.HandleFunc("POST /portal/{customer}/cancel", func(w http.ResponseWriter, r *http.Request) {
+ _ = r.ParseForm()
+ err := f.cancel(r.Context(), r.PathValue("customer"), r.PostForm.Get("subscription"))
+ f.redirect(w, r, f.pageURL("portal", r.PathValue("customer")), err)
+ })
+}
+
+// t is the text for key.
+func (f *Fake) t(key string, args ...any) string {
+ if f.opts.Lookup == nil {
+ return key
+ }
+ return f.opts.Lookup(key, args...)
+}
+
+// pageURL is the address of a page, under PageBase.
+func (f *Fake) pageURL(kind, id string) string {
+ return f.opts.PageBase + "/" + kind + "/" + url.PathEscape(id)
+}
+
+// action is a page's form action: PageBase's path, so the form posts to
+// whichever host the page was loaded from.
+func (f *Fake) action(kind, id, verb string) string {
+ path := f.opts.PageBase
+ if u, err := url.Parse(path); err == nil {
+ path = u.Path
+ }
+ return path + "/" + kind + "/" + url.PathEscape(id) + "/" + verb
+}
+
+func (f *Fake) checkoutPage(w http.ResponseWriter, r *http.Request) {
+ // The page is where the reader sees time pass in the sandbox.
+ f.Tick()
+ id := r.PathValue("id")
+ f.mu.Lock()
+ s := f.sessions[id]
+ if s == nil || s.Status != "open" {
+ f.mu.Unlock()
+ f.expired(w, s == nil)
+ return
+ }
+ plan, amount := "", ""
+ if p := f.price(s.price); p != nil {
+ plan, amount = p.plan, f.priceLine(p)
+ }
+ cancelURL := s.cancelURL
+ f.mu.Unlock()
+
+ c := &checkoutData{
+ Item: f.t("abono.sandbox.checkout_item", "plan", plan, "price", amount),
+ Pay: f.t("abono.sandbox.pay"),
+ Decline: f.t("abono.sandbox.decline"),
+ PayAction: f.action("checkout", id, "pay"), DeclineAction: f.action("checkout", id, "decline"),
+ }
+ if cancelURL != "" {
+ c.Leave, c.LeaveHref = f.t("abono.sandbox.leave"), safeURL(cancelURL)
+ }
+ f.render(w, http.StatusOK, pageData{
+ Title: f.t("abono.sandbox.checkout_title"), Banner: f.t("abono.sandbox.banner"),
+ Intro: f.t("abono.sandbox.checkout_intro"), Checkout: c,
+ })
+}
+
+// priceLine is the price as abono.price.per_<interval> says it, or the
+// bare amount for a one-off. Hold f.mu.
+func (f *Fake) priceLine(p *price) string {
+ amount := money(p.amount, p.currency)
+ if p.interval == "" {
+ return amount
+ }
+ return f.t("abono.price.per_"+p.interval, "amount", amount)
+}
+
+func (f *Fake) portalPage(w http.ResponseWriter, r *http.Request) {
+ f.Tick()
+ customer := r.PathValue("customer")
+ f.mu.Lock()
+ var items []portalItem
+ for _, id := range f.order {
+ s := f.subs[id]
+ if s.customer != customer || !live(s.status) {
+ continue
+ }
+ plan := s.price
+ if p := f.prices[s.price]; p != nil && p.plan != "" {
+ plan = p.plan
+ }
+ it := portalItem{ID: s.id, Plan: f.t("abono.sandbox.portal_plan", "plan", plan), CancelAction: f.action("portal", customer, "cancel")}
+ if !s.cancel {
+ it.Cancel = f.t("abono.sandbox.portal_cancel")
+ }
+ items = append(items, it)
+ }
+ back := f.portals[customer]
+ f.mu.Unlock()
+
+ d := pageData{
+ Title: f.t("abono.sandbox.portal_title"), Banner: f.t("abono.sandbox.banner"),
+ Intro: f.t("abono.sandbox.portal_intro"), Portal: items,
+ }
+ if back != "" {
+ d.Return, d.ReturnHref = f.t("abono.sandbox.portal_return"), safeURL(back)
+ }
+ f.render(w, http.StatusOK, d)
+}
+
+// expired is the page for a session that is over (410) or never was
+// (404).
+func (f *Fake) expired(w http.ResponseWriter, unknown bool) {
+ status := http.StatusGone
+ if unknown {
+ status = http.StatusNotFound
+ }
+ f.render(w, status, pageData{
+ Title: f.t("abono.sandbox.expired"), Banner: f.t("abono.sandbox.banner"), Expired: f.t("abono.sandbox.expired"),
+ })
+}
+
+// redirect sends the browser on to back after an action, or shows the
+// expired page when there was nothing to act on.
+func (f *Fake) redirect(w http.ResponseWriter, r *http.Request, back string, err error) {
+ switch {
+ case errors.Is(err, errUnknown), errors.Is(err, errGone):
+ f.expired(w, errors.Is(err, errUnknown))
+ case err != nil:
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ case back == "":
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ http.Redirect(w, r, back, http.StatusSeeOther)
+ }
+}
+
+func (f *Fake) render(w http.ResponseWriter, status int, d pageData) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(status)
+ _ = page.Execute(w, d)
+}
+
+// money is amount in currency's minor units as digits and the code,
+// "60.00 EUR": a stand-in, not a locale's way of writing money.
+func money(amount int64, code string) string {
+ code = strings.ToUpper(code)
+ switch code {
+ case "BIF", "CLP", "DJF", "GNF", "JPY", "KMF", "KRW", "MGA", "PYG", "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF":
+ return strconv.FormatInt(amount, 10) + " " + code
+ }
+ sign := ""
+ if amount < 0 {
+ sign, amount = "-", -amount
+ }
+ return fmt.Sprintf("%s%d.%02d %s", sign, amount/100, amount%100, code)
+}
+
+// safeURL lets an http(s) address through as a link and turns anything
+// else (javascript:, say) into a harmless "#".
+func safeURL(s string) template.URL {
+ u, err := url.Parse(s)
+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
+ return "#"
+ }
+ return template.URL(u.String())
+}
diff --git a/reconcile_test.go b/reconcile_test.go
index 8b00980..d93b9d5 100644
--- a/reconcile_test.go
+++ b/reconcile_test.go
@@ -63,7 +63,7 @@ func TestReconcileTellsEverySubjectHoldingAnything(t *testing.T) {
d.put(t, subSpec{ID: "sub_2", Status: "active", Price: "price_Z", Customer: "cus_2", Email: "bo@example.com", End: t0.Add(30 * day)})
d.put(t, subSpec{ID: "sub_3", Status: "active", Price: "price_M", Customer: "cus_3", Email: "di@example.com", End: t0.Add(30 * day)})
for _, id := range []string{"sub_1", "sub_2"} {
- if err := s.Sync(ctx, asSubscription(t, d.subs[id]), abono.Hint{Subject: map[string]string{"sub_1": "ana", "sub_2": "bo"}[id]}); err != nil {
+ if err := s.Sync(ctx, d.sub(t, id), abono.Hint{Subject: map[string]string{"sub_1": "ana", "sub_2": "bo"}[id]}); err != nil {
t.Fatalf("Sync %s: %v", id, err)
}
}
diff --git a/sandbox.go b/sandbox.go
new file mode 100644
index 0000000..a088429
--- /dev/null
+++ b/sandbox.go
@@ -0,0 +1,104 @@
+package abono
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+
+ "amadan.net/rastrillo/abono/internal/fakestripe"
+ "amadan.net/rastrillo/abono/internal/stripeapi"
+)
+
+// SandboxPath is where an app mounts SandboxHandler. The sandbox's
+// stand-in Checkout and Portal pages are under it, on the app's own
+// origin, so the app's csrf.Protect passes their forms like any other:
+// it exempts nothing here.
+const SandboxPath = "/abono/sandbox/"
+
+// sandboxAPI is the address the sandbox's Stripe client calls. Nothing
+// leaves the process: the client's transport hands each request to the
+// fake directly.
+const sandboxAPI = "http://abono-sandbox.invalid"
+
+// startSandbox runs a fake Stripe inside the service: Checkout and Portal
+// return its pages under SandboxPath, its events go straight to receive
+// with no HTTP and no signature, and it answers any price id, guessing the
+// interval from the id (price_annual is yearly, anything unmarked
+// monthly).
+func (s *Service) startSandbox() {
+ f := fakestripe.New(fakestripe.Options{
+ Now: s.now,
+ IDTag: runTag(),
+ PageBase: strings.TrimRight(s.cfg.Origin, "/") + strings.TrimSuffix(SandboxPath, "/"),
+ Lookup: text,
+ AnyPrice: true,
+ Deliver: func(ctx context.Context, body []byte) error {
+ if status, msg := s.receive(ctx, body); status != http.StatusOK {
+ return fmt.Errorf("abono: sandbox event: %d %s", status, msg)
+ }
+ return nil
+ },
+ })
+ s.sandbox = f
+ s.stripe = &stripeapi.Client{Key: "sk_sandbox", Base: sandboxAPI, HTTP: &http.Client{Transport: inProcess{f.Handler()}}}
+ s.cfg.Logger.Info("abono: sandbox on: payments are pretend", "pages", strings.TrimRight(s.cfg.Origin, "/")+SandboxPath)
+}
+
+// runTag is a short tag unique to this run, for the sandbox's ids: its
+// fake forgets everything when the app restarts, and the database does
+// not.
+func runTag() string {
+ b := make([]byte, 4)
+ _, _ = rand.Read(b)
+ return hex.EncodeToString(b)
+}
+
+// SandboxHandler serves the sandbox's pages. The app mounts it at
+// SandboxPath, behind its usual csrf.Protect. Without Config.Sandbox it
+// answers 404 to everything, so an app may mount it unconditionally.
+func (s *Service) SandboxHandler() http.Handler {
+ if s == nil || s.sandbox == nil {
+ return http.NotFoundHandler()
+ }
+ pages := http.StripPrefix(strings.TrimSuffix(SandboxPath, "/"), s.sandbox.Handler())
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // The pages only: the fake's API is the service's, in-process.
+ if !strings.HasPrefix(r.URL.Path, SandboxPath) || strings.HasPrefix(r.URL.Path, SandboxPath+"v1/") {
+ http.NotFound(w, r)
+ return
+ }
+ pages.ServeHTTP(w, r)
+ })
+}
+
+// inProcess is a transport that serves each request with h, in the
+// caller's goroutine.
+type inProcess struct{ h http.Handler }
+
+func (t inProcess) RoundTrip(req *http.Request) (*http.Response, error) {
+ w := httptest.NewRecorder()
+ t.h.ServeHTTP(w, req)
+ return w.Result(), nil
+}
+
+// catalog is abono's English, which arrives with the reviewed copy. Until
+// then it is empty, and every key reads as itself, as a key missing from
+// rastrillo's catalogs does.
+var catalog map[string]string
+
+// text is the string for key, its {name} placeholders filled from
+// alternating name/value args; the key itself when the catalog has none.
+func text(key string, args ...any) string {
+ s, ok := catalog[key]
+ if !ok {
+ return key
+ }
+ for i := 0; i+1 < len(args); i += 2 {
+ s = strings.ReplaceAll(s, "{"+fmt.Sprint(args[i])+"}", fmt.Sprint(args[i+1]))
+ }
+ return s
+}
diff --git a/sandbox_test.go b/sandbox_test.go
new file mode 100644
index 0000000..ca458be
--- /dev/null
+++ b/sandbox_test.go
@@ -0,0 +1,243 @@
+package abono_test
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo/csrf"
+ "amadan.net/rastrillo/rastrillo/migrate"
+ "amadan.net/rastrillo/rastrillo/sessions"
+
+ "amadan.net/rastrillo/abono"
+)
+
+// clock is a test clock that server goroutines may read while the test
+// moves it.
+type clock struct {
+ mu sync.Mutex
+ t time.Time
+}
+
+func (c *clock) Now() time.Time {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.t
+}
+
+func (c *clock) Advance(d time.Duration) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.t = c.t.Add(d)
+}
+
+// browser is a same-origin browser: it sends Origin on a POST, as every
+// current browser does on a form, and reports redirects rather than
+// following them.
+type browser struct {
+ t *testing.T
+ origin string
+}
+
+func (b browser) do(method, u string, form url.Values) (int, string, string) {
+ b.t.Helper()
+ req, err := http.NewRequest(method, u, strings.NewReader(form.Encode()))
+ if err != nil {
+ b.t.Fatal(err)
+ }
+ if method == http.MethodPost {
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ if b.origin != "" {
+ req.Header.Set("Origin", b.origin)
+ }
+ }
+ c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ resp, err := c.Do(req)
+ if err != nil {
+ b.t.Fatalf("%s %s: %v", method, u, err)
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ return resp.StatusCode, resp.Header.Get("Location"), string(body)
+}
+
+// TestSandboxRunsTheSameFlowWithoutStripe is abonotest's flow against a
+// Sandbox service: checkout, pay, cancel in the portal, the period ends.
+// The fake runs in-process; its pages are the app's, at SandboxPath, and
+// its events reach the service without HTTP.
+func TestSandboxRunsTheSameFlowWithoutStripe(t *testing.T) {
+ ctx := context.Background()
+ clk := &clock{t: t0}
+ d := openDB(t)
+ if _, err := migrate.Apply(ctx, d, migrate.Merge(sessions.Schema, abono.Schema)); err != nil {
+ t.Fatalf("migrate.Apply: %v", err)
+ }
+ srv := httptest.NewUnstartedServer(nil) // on 127.0.0.1
+ origin := "http://" + srv.Listener.Addr().String()
+ rec := &recorder{subject: "ana"}
+ s, err := abono.New(abono.Config{
+ DB: d.G, Origin: origin, Sandbox: true, Holder: rec.Holder, OnChange: rec.OnChange, Now: clk.Now,
+ Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)),
+ })
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ if !s.On() {
+ t.Fatal("a sandbox service is not On")
+ }
+ // No Stripe to read intervals from: the sandbox takes them from the
+ // price ids.
+ if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_annual"}, {Plan: "monthly", Sold: "price_monthly"}}); err != nil {
+ t.Fatalf("SeedPlans: %v", err)
+ }
+ mux := http.NewServeMux()
+ mux.Handle(abono.SandboxPath, s.SandboxHandler())
+ mux.HandleFunc("POST "+abono.WebhookPath, s.ServeWebhook)
+ srv.Config.Handler = csrf.Protect(origin, csrf.Exempt(abono.WebhookPath))(mux)
+ srv.Start()
+ t.Cleanup(srv.Close)
+ b := browser{t: t, origin: origin}
+
+ u, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", SuccessPath: "/done", CancelPath: "/join"})
+ if err != nil {
+ t.Fatalf("Checkout: %v", err)
+ }
+ if !strings.HasPrefix(u, origin+abono.SandboxPath+"checkout/") {
+ t.Fatalf("Checkout URL = %q, want the sandbox's page on this site", u)
+ }
+ code, _, page := b.do(http.MethodGet, u, nil)
+ if code != http.StatusOK {
+ t.Fatalf("checkout page: status %d", code)
+ }
+ for _, key := range []string{"abono.sandbox.banner", "abono.sandbox.checkout_title", "abono.sandbox.pay", "abono.sandbox.decline"} {
+ if !strings.Contains(page, key) {
+ t.Errorf("the checkout page does not show %s", key)
+ }
+ }
+
+ // The pages are browser forms: csrf.Protect turns away a POST from
+ // anywhere else.
+ if code, _, _ := (browser{t: t, origin: "http://evil.example"}).do(http.MethodPost, u+"/pay", nil); code != http.StatusForbidden {
+ t.Errorf("a cross-site Pay: status %d, want 403", code)
+ }
+
+ code, done, _ := b.do(http.MethodPost, u+"/pay", url.Values{"email": {"ana@example.com"}})
+ if code != http.StatusSeeOther || !strings.HasPrefix(done, origin+"/done?session_id=") {
+ t.Fatalf("Pay: %d to %q", code, done)
+ }
+ if !hasPlan(t, s, "ana", "annual") {
+ t.Fatal("HasPlan is false after paying in the sandbox")
+ }
+ asked, changes := rec.take()
+ eq(t, "Holder asked", asked, []string{"ana@example.com"})
+ eq(t, "OnChange", changes, []string{"ana"})
+ back, _ := url.Parse(done)
+ if f, err := s.Finish(ctx, back.Query().Get("session_id")); err != nil || !f.Paid || f.Subject != "ana" {
+ t.Errorf("Finish = %+v, %v", f, err)
+ }
+
+ pu, err := s.Portal(ctx, "ana", "/account")
+ if err != nil {
+ t.Fatalf("Portal: %v", err)
+ }
+ if !strings.HasPrefix(pu, origin+abono.SandboxPath+"portal/") {
+ t.Fatalf("Portal URL = %q", pu)
+ }
+ if code, _, page = b.do(http.MethodGet, pu, nil); code != http.StatusOK || !strings.Contains(page, "abono.sandbox.portal_cancel") {
+ t.Fatalf("portal page: status %d\n%s", code, page)
+ }
+ hs, err := s.Holdings(ctx, "ana")
+ if err != nil || len(hs) != 1 {
+ t.Fatalf("Holdings = %+v, %v", hs, err)
+ }
+ if code, loc, _ := b.do(http.MethodPost, pu+"/cancel", url.Values{"subscription": {hs[0].SubscriptionID}}); code != http.StatusSeeOther || loc != pu {
+ t.Fatalf("Cancel: %d to %q", code, loc)
+ }
+ hs, _ = s.Holdings(ctx, "ana")
+ if got := abono.Describe(hs[0], clk.Now()).Key; got != abono.StatusCancelling {
+ t.Errorf("after cancelling: %s, want %s", got, abono.StatusCancelling)
+ }
+
+ // Time passes. The sandbox notices on its next page.
+ clk.Advance(366 * day)
+ if code, _, _ = b.do(http.MethodGet, pu, nil); code != http.StatusOK {
+ t.Errorf("portal page later: status %d", code)
+ }
+ if hasPlan(t, s, "ana", "annual") {
+ t.Error("HasPlan is true past the period end")
+ }
+ if hs, _ = s.Holdings(ctx, "ana"); hs[0].Status != "canceled" {
+ t.Errorf("past the period end the subscription is %s, want canceled", hs[0].Status)
+ }
+
+ // The sandbox's handler is its pages, not the fake's API, and the
+ // webhook takes nothing: there is no secret to sign with.
+ if code, _, _ = b.do(http.MethodGet, origin+abono.SandboxPath+"v1/subscriptions", nil); code != http.StatusNotFound {
+ t.Errorf("the fake's API on the site: status %d, want 404", code)
+ }
+ body := event("evt_1", "customer.subscription.created", map[string]any{"id": hs[0].SubscriptionID})
+ wantStatus(t, deliverWith(s, body, sign("whsec_any", body, clk.Now())), http.StatusBadRequest)
+}
+
+func TestSandboxHandlerIsOffWithoutTheSandbox(t *testing.T) {
+ now := t0
+ s, _ := newService(t, &now, abono.Config{})
+ w := httptest.NewRecorder()
+ s.SandboxHandler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, abono.SandboxPath+"checkout/cs_1", nil))
+ if w.Code != http.StatusNotFound {
+ t.Errorf("status %d, want 404", w.Code)
+ }
+}
+
+// TestSandboxSurvivesARestart: the sandbox's fake starts empty each time
+// the app does, while the database keeps the last run's rows and events.
+// A payment after a restart is a new subscription, not the old sub_1.
+func TestSandboxSurvivesARestart(t *testing.T) {
+ ctx := context.Background()
+ d := openDB(t)
+ if _, err := migrate.Apply(ctx, d, migrate.Merge(sessions.Schema, abono.Schema)); err != nil {
+ t.Fatalf("migrate.Apply: %v", err)
+ }
+ const origin = "http://localhost:8080"
+ run := func(subject string) {
+ t.Helper()
+ rec := &recorder{subject: subject}
+ s, err := abono.New(abono.Config{DB: d.G, Origin: origin, Sandbox: true, Holder: rec.Holder, OnChange: rec.OnChange,
+ Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))})
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_annual"}}); err != nil {
+ t.Fatalf("SeedPlans: %v", err)
+ }
+ u, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", Email: subject + "@example.com", SuccessPath: "/done"})
+ if err != nil {
+ t.Fatalf("Checkout: %v", err)
+ }
+ w := httptest.NewRecorder()
+ s.SandboxHandler().ServeHTTP(w, httptest.NewRequest(http.MethodPost, strings.TrimPrefix(u, origin)+"/pay", nil))
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("Pay: status %d", w.Code)
+ }
+ if !hasPlan(t, s, subject, "annual") {
+ t.Errorf("%s does not hold annual after paying", subject)
+ }
+ }
+ run("ana")
+ run("bo") // the app restarted: a new sandbox, the same database
+ var subs []string
+ if err := d.G.Raw(`SELECT subject FROM abono_subscriptions ORDER BY subject`).Scan(&subs).Error; err != nil {
+ t.Fatal(err)
+ }
+ if strings.Join(subs, ",") != "ana,bo" {
+ t.Errorf("subscriptions are held by %v, want ana and bo", subs)
+ }
+}
diff --git a/stripe_double_test.go b/stripe_double_test.go
index 687cc27..2cbbc7b 100644
--- a/stripe_double_test.go
+++ b/stripe_double_test.go
@@ -3,11 +3,9 @@ package abono_test
import (
"context"
"encoding/json"
- "fmt"
"net/http"
"net/http/httptest"
"net/url"
- "sort"
"sync"
"testing"
"time"
@@ -15,111 +13,35 @@ import (
"gorm.io/gorm"
"amadan.net/rastrillo/abono"
+ "amadan.net/rastrillo/abono/internal/fakestripe"
"amadan.net/rastrillo/abono/internal/stripeapi"
)
-// stripeDouble is the little of Stripe's API these tests need: the
-// subscriptions and Checkout sessions a test put there, promotion codes,
-// and a record of every Checkout and Portal session asked for. A later
-// task replaces it with the shared fake.
+// stripeDouble is fakestripe served for one test, with the helpers these
+// tests set it up through: subscriptions and Checkout sessions put there
+// as they are, the prices seedPlans names, and what Checkout and the
+// Portal were asked for.
type stripeDouble struct {
- srv *httptest.Server
-
- mu sync.Mutex
- subs map[string]any
- sessions map[string]any
- promos map[string]string // code -> promo_...
- checkouts []url.Values
- portals []url.Values
-
- prices map[string]any
- priceFetches map[string]int
-
- // fetched, when set, is called each time a subscription is read.
- fetched func()
+ *fakestripe.Fake
+ URL string
}
func newStripeDouble(t *testing.T) *stripeDouble {
t.Helper()
- d := &stripeDouble{subs: map[string]any{}, sessions: map[string]any{}, promos: map[string]string{},
- prices: map[string]any{}, priceFetches: map[string]int{}}
+ srv := httptest.NewUnstartedServer(nil)
+ d := &stripeDouble{URL: "http://" + srv.Listener.Addr().String()}
+ // No webhook: these tests deliver the events they mean to.
+ d.Fake = fakestripe.New(fakestripe.Options{PageBase: d.URL, Now: func() time.Time { return t0 }})
+ srv.Config.Handler = d.Handler()
+ srv.Start()
+ t.Cleanup(srv.Close)
// The prices seedPlans names, so Checkout can learn their intervals.
d.price("price_A", "year", 6000, "eur")
d.price("price_old", "year", 4000, "eur")
d.price("price_M", "month", 600, "eur")
- mux := http.NewServeMux()
- mux.HandleFunc("GET /v1/prices/{id}", func(w http.ResponseWriter, r *http.Request) {
- d.mu.Lock()
- d.priceFetches[r.PathValue("id")]++
- d.mu.Unlock()
- d.reply(w, d.prices, r.PathValue("id"))
- })
- mux.HandleFunc("GET /v1/subscriptions", func(w http.ResponseWriter, r *http.Request) {
- // Every subscription in one page, whatever its status, oldest id first.
- d.mu.Lock()
- ids := make([]string, 0, len(d.subs))
- for id := range d.subs {
- ids = append(ids, id)
- }
- sort.Strings(ids)
- data := make([]any, 0, len(ids))
- for _, id := range ids {
- data = append(data, d.subs[id])
- }
- d.mu.Unlock()
- writeJSON(w, http.StatusOK, map[string]any{"data": data, "has_more": false})
- })
- mux.HandleFunc("GET /v1/subscriptions/{id}", func(w http.ResponseWriter, r *http.Request) {
- d.reply(w, d.subs, r.PathValue("id"))
- if d.fetched != nil {
- d.fetched()
- }
- })
- mux.HandleFunc("GET /v1/checkout/sessions/{id}", func(w http.ResponseWriter, r *http.Request) {
- d.reply(w, d.sessions, r.PathValue("id"))
- })
- mux.HandleFunc("POST /v1/checkout/sessions", func(w http.ResponseWriter, r *http.Request) {
- _ = r.ParseForm()
- d.mu.Lock()
- d.checkouts = append(d.checkouts, r.PostForm)
- id := fmt.Sprintf("cs_%d", len(d.checkouts))
- d.mu.Unlock()
- writeJSON(w, http.StatusOK, map[string]any{"id": id, "url": "https://checkout.stripe.test/" + id})
- })
- mux.HandleFunc("GET /v1/promotion_codes", func(w http.ResponseWriter, r *http.Request) {
- d.mu.Lock()
- id, ok := d.promos[r.URL.Query().Get("code")]
- d.mu.Unlock()
- data := []any{}
- if ok {
- data = append(data, map[string]any{"id": id})
- }
- writeJSON(w, http.StatusOK, map[string]any{"data": data})
- })
- mux.HandleFunc("POST /v1/billing_portal/sessions", func(w http.ResponseWriter, r *http.Request) {
- _ = r.ParseForm()
- d.mu.Lock()
- d.portals = append(d.portals, r.PostForm)
- d.mu.Unlock()
- writeJSON(w, http.StatusOK, map[string]any{"url": "https://billing.stripe.test/" + r.PostForm.Get("customer")})
- })
- d.srv = httptest.NewServer(mux)
- t.Cleanup(d.srv.Close)
return d
}
-func (d *stripeDouble) reply(w http.ResponseWriter, objects map[string]any, id string) {
- d.mu.Lock()
- obj, ok := objects[id]
- d.mu.Unlock()
- if !ok {
- writeJSON(w, http.StatusNotFound, map[string]any{"error": map[string]any{
- "type": "invalid_request_error", "code": "resource_missing", "message": "No such object: " + id}})
- return
- }
- writeJSON(w, http.StatusOK, obj)
-}
-
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
@@ -131,7 +53,7 @@ func (d *stripeDouble) service(t *testing.T, now *time.Time, cfg abono.Config) (
t.Helper()
cfg.Key = "rk_test_double"
cfg.WebhookSecrets = []string{"whsec_double"}
- cfg.StripeBase = d.srv.URL
+ cfg.StripeBase = d.URL
if cfg.Origin == "" {
cfg.Origin = "https://site.example"
}
@@ -147,52 +69,41 @@ type subSpec struct {
Metadata map[string]string
}
-// put stores sp in the double, with its customer expanded, and returns
-// it as the client would read it.
+// put stores sp in the double and returns it as the client reads it, its
+// customer expanded.
func (d *stripeDouble) put(t *testing.T, sp subSpec) stripeapi.Subscription {
t.Helper()
if sp.Interval == "" {
sp.Interval = "year"
}
- var end int64
- if !sp.End.IsZero() {
- end = sp.End.Unix()
+ d.PutSubscription(fakestripe.Subscription{
+ ID: sp.ID, Customer: sp.Customer, Email: sp.Email, Name: sp.Name, Status: sp.Status,
+ Price: sp.Price, Interval: sp.Interval, PeriodEnd: sp.End, CancelAtPeriodEnd: sp.Cancel, Metadata: sp.Metadata,
+ })
+ return d.sub(t, sp.ID)
+}
+
+// sub is subscription id as the client reads it.
+func (d *stripeDouble) sub(t *testing.T, id string) stripeapi.Subscription {
+ t.Helper()
+ b, ok := d.SubscriptionJSON(id)
+ if !ok {
+ t.Fatalf("no subscription %s in the double", id)
}
- obj := map[string]any{
- "id": sp.ID,
- "status": sp.Status,
- "cancel_at_period_end": sp.Cancel,
- "customer": map[string]any{"id": sp.Customer, "email": sp.Email, "name": sp.Name},
- "items": map[string]any{"data": []any{map[string]any{
- "current_period_end": end,
- "price": map[string]any{"id": sp.Price, "recurring": map[string]any{"interval": sp.Interval}},
- }}},
- "metadata": sp.Metadata,
+ var s stripeapi.Subscription
+ if err := json.Unmarshal(b, &s); err != nil {
+ t.Fatal(err)
}
- d.mu.Lock()
- d.subs[sp.ID] = obj
- d.mu.Unlock()
- return asSubscription(t, obj)
+ return s
}
// price stores a recurring price.
func (d *stripeDouble) price(id, interval string, amount int64, currency string) {
- d.mu.Lock()
- defer d.mu.Unlock()
- d.prices[id] = map[string]any{"id": id, "unit_amount": amount, "currency": currency,
- "recurring": map[string]any{"interval": interval}}
+ d.AddPrice(id, "", amount, currency, interval)
}
// fetches is how many times each price has been read.
-func (d *stripeDouble) fetches() map[string]int {
- d.mu.Lock()
- defer d.mu.Unlock()
- out := map[string]int{}
- for k, v := range d.priceFetches {
- out[k] = v
- }
- return out
-}
+func (d *stripeDouble) fetches() map[string]int { return d.PriceFetches() }
// session stores a Checkout session. A complete one is paid, an open one
// unpaid; paying overrides that.
@@ -201,42 +112,24 @@ func (d *stripeDouble) session(id, status, subID, ref, email string) {
if status == "complete" {
paying = "paid"
}
- d.mu.Lock()
- defer d.mu.Unlock()
- d.sessions[id] = map[string]any{
- "id": id, "status": status, "payment_status": paying, "subscription": subID, "client_reference_id": ref,
- "customer_details": map[string]any{"email": email},
- }
+ d.PutSession(fakestripe.Session{ID: id, Status: status, PaymentStatus: paying, Subscription: subID,
+ ClientReferenceID: ref, Email: email})
}
// paying sets a session's payment_status.
func (d *stripeDouble) paying(id, status string) {
- d.mu.Lock()
- defer d.mu.Unlock()
- d.sessions[id].(map[string]any)["payment_status"] = status
+ s, _ := d.Session(id)
+ s.PaymentStatus = status
+ d.PutSession(s)
}
func (d *stripeDouble) lastCheckout(t *testing.T) url.Values {
t.Helper()
- d.mu.Lock()
- defer d.mu.Unlock()
- if len(d.checkouts) == 0 {
+ forms := d.CheckoutForms()
+ if len(forms) == 0 {
t.Fatal("no Checkout session was created")
}
- return d.checkouts[len(d.checkouts)-1]
-}
-
-func asSubscription(t *testing.T, obj any) stripeapi.Subscription {
- t.Helper()
- b, err := json.Marshal(obj)
- if err != nil {
- t.Fatal(err)
- }
- var s stripeapi.Subscription
- if err := json.Unmarshal(b, &s); err != nil {
- t.Fatal(err)
- }
- return s
+ return forms[len(forms)-1]
}
// recorder is an app's Holder and OnChange, counting what it was asked.
diff --git a/sync_test.go b/sync_test.go
index b96cf37..77bc1f1 100644
--- a/sync_test.go
+++ b/sync_test.go
@@ -326,7 +326,7 @@ func TestFinishAndWebhookRace(t *testing.T) {
// middle of the webhook's write, which is the race.
fetched := make(chan struct{})
var once sync.Once
- d.fetched = func() { once.Do(func() { close(fetched) }) }
+ d.OnSubscriptionFetch(func() { once.Do(func() { close(fetched) }) })
rec.pause = func() {
select {
case <-fetched:
diff --git a/webhook.go b/webhook.go
index 92c57b5..017f899 100644
--- a/webhook.go
+++ b/webhook.go
@@ -1,6 +1,7 @@
package abono
import (
+ "context"
"encoding/json"
"errors"
"io"
@@ -128,21 +129,29 @@ func (s *Service) ServeWebhook(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad signature", http.StatusBadRequest)
return
}
+ if status, msg := s.receive(r.Context(), body); status != http.StatusOK {
+ http.Error(w, msg, status)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// receive does what an event says, once: the work of ServeWebhook after
+// the signature check, and all of it for the sandbox, whose fake hands
+// events straight in. It answers the status the webhook should, with a
+// message for anything but 200; a failure is logged here.
+func (s *Service) receive(ctx context.Context, body []byte) (int, string) {
var e event
if err := json.Unmarshal(body, &e); err != nil || e.ID == "" {
- http.Error(w, "not an event", http.StatusBadRequest)
- return
+ return http.StatusBadRequest, "not an event"
}
- ctx := r.Context()
var seen int64
if err := s.cfg.DB.WithContext(ctx).Table("abono_events").Where("id = ?", e.ID).Count(&seen).Error; err != nil {
- s.fail(w, "webhook: read events", err, "event", e.ID)
- return
+ return s.fail("webhook: read events", err, "event", e.ID)
}
if seen > 0 {
- w.WriteHeader(http.StatusOK)
- return
+ return http.StatusOK, ""
}
if id := e.subscriptionID(); id != "" {
@@ -153,12 +162,10 @@ func (s *Service) ServeWebhook(w http.ResponseWriter, r *http.Request) {
// delivery is from another mode). Retrying cannot help.
s.cfg.Logger.Warn("abono: webhook: subscription no longer exists", "event", e.ID, "type", e.Type, "subscription", id)
case err != nil:
- s.fail(w, "webhook: fetch subscription", err, "event", e.ID, "subscription", id)
- return
+ return s.fail("webhook: fetch subscription", err, "event", e.ID, "subscription", id)
default:
if err := s.sync(ctx, sub, e.hint(sub)); err != nil {
- s.fail(w, "webhook: sync", err, "event", e.ID, "subscription", id)
- return
+ return s.fail("webhook: sync", err, "event", e.ID, "subscription", id)
}
s.cfg.Logger.Info("abono: synced", "event", e.ID, "type", e.Type, "subscription", id, "status", sub.Status)
}
@@ -171,10 +178,9 @@ func (s *Service) ServeWebhook(w http.ResponseWriter, r *http.Request) {
// retry redoing the work is safe.
if err := s.cfg.DB.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).
Create(&eventRow{ID: e.ID, Type: e.Type, ReceivedAt: s.now()}).Error; err != nil {
- s.fail(w, "webhook: record event", err, "event", e.ID)
- return
+ return s.fail("webhook: record event", err, "event", e.ID)
}
- w.WriteHeader(http.StatusOK)
+ return http.StatusOK, ""
}
// ServeSync is the daily tick, POST SyncPath: Reconcile, inside the request
@@ -188,20 +194,21 @@ func (s *Service) ServeSync(w http.ResponseWriter, r *http.Request) {
}
rep, err := s.Reconcile(r.Context())
if err != nil {
- s.fail(w, "reconcile", err, "subscriptions", rep.Subscriptions, "subjects", rep.Subjects)
+ status, msg := s.fail("reconcile", err, "subscriptions", rep.Subscriptions, "subjects", rep.Subjects)
+ http.Error(w, msg, status)
return
}
s.cfg.Logger.Info("abono: reconciled", "subscriptions", rep.Subscriptions, "subjects", rep.Subjects)
w.WriteHeader(http.StatusNoContent)
}
-// fail logs err and answers 500. A Stripe 401 is a revoked or wrong key:
-// the log says so, and the answer is still 500, so Stripe retries once the
-// key is fixed.
-func (s *Service) fail(w http.ResponseWriter, msg string, err error, kv ...any) {
+// fail logs err and returns the 500 to answer. A Stripe 401 is a revoked
+// or wrong key: the log says so, and the answer is still 500, so Stripe
+// retries once the key is fixed.
+func (s *Service) fail(msg string, err error, kv ...any) (int, string) {
s.cfg.Logger.Error("abono: "+msg, append(kv, "err", err)...)
s.noteRefusedKey(err)
- http.Error(w, "abono failed", http.StatusInternalServerError)
+ return http.StatusInternalServerError, "abono failed"
}
// noteRefusedKey logs when err is Stripe refusing the API key.
diff --git a/webhook_test.go b/webhook_test.go
index 3df88e6..131031f 100644
--- a/webhook_test.go
+++ b/webhook_test.go
@@ -14,6 +14,7 @@ import (
"net/http/httptest"
"strings"
"sync"
+ "sync/atomic"
"testing"
"time"
@@ -119,7 +120,7 @@ func TestWebhookIsIdempotent(t *testing.T) {
Metadata: map[string]string{"abono_subject": "ana"}})
var mu sync.Mutex
fetches := 0
- d.fetched = func() { mu.Lock(); fetches++; mu.Unlock() }
+ d.OnSubscriptionFetch(func() { mu.Lock(); fetches++; mu.Unlock() })
body := event("evt_1", "customer.subscription.created", map[string]any{"id": "sub_1"})
wantStatus(t, deliver(t, s, body), http.StatusOK)
@@ -145,13 +146,13 @@ func TestWebhookUnreadEventIs200(t *testing.T) {
now := t0
d := newStripeDouble(t)
s, db := d.service(t, &now, abono.Config{})
- fetched := false
- d.fetched = func() { fetched = true }
+ var fetched atomic.Bool
+ d.OnSubscriptionFetch(func() { fetched.Store(true) })
wantStatus(t, deliver(t, s, event("evt_1", "customer.created", map[string]any{"id": "cus_1"})), http.StatusOK)
// A checkout session that is not a subscription has none to read.
wantStatus(t, deliver(t, s, event("evt_2", "checkout.session.completed", map[string]any{"id": "cs_1", "mode": "payment"})), http.StatusOK)
- if fetched {
+ if fetched.Load() {
t.Error("a subscription was read for an event that names none")
}
if got := db.dump(eventsQuery); got != "evt_1|customer.created\nevt_2|checkout.session.completed\n" {