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.
15 files changed,
+2664
−193
abono.go+16 −9abono_test.go+2 −2abonotest/abonotest.go+159 −0abonotest/abonotest_test.go+503 −0checkout_test.go+4 −5internal/fakestripe/fake.go+861 −0internal/fakestripe/fake_test.go+435 −0internal/fakestripe/pages.go+259 −0reconcile_test.go+1 −1sandbox.go+104 −0sandbox_test.go+243 −0stripe_double_test.go+44 −151sync_test.go+1 −1webhook.go+27 −20webhook_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
Intent
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" { |