abono: offers, comps and the holding rules
Summary
Adds the abono Service (Config, New with all-or-nothing Key and WebhookSecrets, sandbox loopback check), offers and plan seeding (ParsePlans, SeedPlans, AddPrice, Offers), comps (Comp, EndComp, both calling OnChange in the same transaction), and Holdings/HasPlan/Describe built on one holding rule.
Agent notes
Rulings R1, R2, R3, R5, R9 applied. Sandbox services are not On yet (Task 7). past_due with no period end grants without Backstop. past_due plus cancel_at_period_end reads as failing. Seeding unarchives an offer and never demotes other sold prices.
11 files changed,
+1379
−2
abono.go+174 −0abono_test.go+177 −0comps.go+78 −0comps_test.go+101 −0describe.go+72 −0describe_test.go+73 −0go.mod+4 −2holdings.go+187 −0holdings_test.go+164 −0offers.go+217 −0offers_test.go+132 −0
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... -count=1 ok amadan.net/rastrillo/abono 0.110s ok amadan.net/rastrillo/abono/internal/stripeapi 0.005s === race === CGO_ENABLED=1 go test ./... -race -count=1 ok amadan.net/rastrillo/abono 2.245s ok amadan.net/rastrillo/abono/internal/stripeapi 1.022s === browser === browser: no browser test file yet, nothing to run
Intent
diff --git a/abono.go b/abono.go| new file mode 100644 |
| index 0000000..180f28b |
| --- /dev/null |
| +++ b/abono.go |
| @@ -0,0 +1,174 @@ |
| +package abono |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "log/slog" |
| + "net" |
| + "net/url" |
| + "strings" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| + |
| + "amadan.net/rastrillo/abono/internal/stripeapi" |
| +) |
| + |
| +// ErrNotFound is returned when a row the caller named does not exist. |
| +var ErrNotFound = errors.New("abono: not found") |
| + |
| +// HolderFunc is the app's answer to "whose is this payment?". Given the |
| +// address and name a buyer paid with, it returns the app's subject for |
| +// them, making one if the app wants paying to be a way in. An app that |
| +// wants sign-in before payment returns "", and the payment is recorded |
| +// unclaimed, by address. It runs inside abono's transaction: use tx, never |
| +// the app's own handle, or it will wait forever on the writer connection. |
| +type HolderFunc func(ctx context.Context, tx *gorm.DB, email, name string) (subject string, err error) |
| + |
| +// ChangeFunc is told that a holding of subject changed. It runs in the same |
| +// transaction as the write, so the app's own state never disagrees with |
| +// abono's, and its error rolls the write back. Use tx, never the app's own |
| +// handle. |
| +type ChangeFunc func(ctx context.Context, tx *gorm.DB, subject string) error |
| + |
| +// Config configures New. DB is required. Key and WebhookSecrets go |
| +// together: both, or neither. |
| +type Config struct { |
| + // DB is the app's database, as *gorm.DB (d.G from rastrillo/db), with |
| + // abono.Schema already applied. |
| + DB *gorm.DB |
| + |
| + // Key is the Stripe account's restricted key (rk_...). |
| + Key string |
| + // WebhookSecrets are the endpoint's signing secrets (whsec_...), more |
| + // than one while rotating. |
| + WebhookSecrets []string |
| + |
| + // Origin is the site's own address, for the URLs Stripe sends people |
| + // back to. |
| + Origin string |
| + |
| + // Holder says whose a payment is. Optional. |
| + Holder HolderFunc |
| + // OnChange is told when a subject's holdings change. 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 bool |
| + |
| + // Now is the clock; default time.Now. Tests set it. |
| + Now func() time.Time |
| + // Logger; default slog.Default(). |
| + Logger *slog.Logger |
| + |
| + // StripeBase points the Stripe client at a fake, for tests only. |
| + StripeBase string |
| +} |
| + |
| +// 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 |
| +} |
| + |
| +// New validates cfg and returns a ready *Service. |
| +// |
| +// Configuration is all or nothing: a Key without WebhookSecrets, or |
| +// WebhookSecrets without a Key, is a half a setup and refuses to boot. With |
| +// neither, the service is off (On is false) and nothing is for sale. New |
| +// does not look at the plans: they are seeded into the database afterwards |
| +// (SeedPlans), and the app may manage them there. |
| +func New(cfg Config) (*Service, error) { |
| + if cfg.DB == nil { |
| + return nil, errors.New("abono: Config.DB is required") |
| + } |
| + |
| + // A blank secret is what an unset environment variable splits to. |
| + var secrets []string |
| + for _, s := range cfg.WebhookSecrets { |
| + if s = strings.TrimSpace(s); s != "" { |
| + secrets = append(secrets, s) |
| + } |
| + } |
| + cfg.WebhookSecrets = secrets |
| + cfg.Key = strings.TrimSpace(cfg.Key) |
| + switch { |
| + case cfg.Key != "" && len(secrets) == 0: |
| + return nil, errors.New("abono: Config.Key is set but Config.WebhookSecrets is not; set both or neither") |
| + case cfg.Key == "" && len(secrets) > 0: |
| + return nil, errors.New("abono: Config.WebhookSecrets is set but Config.Key is not; set both or neither") |
| + } |
| + |
| + if cfg.Sandbox { |
| + if cfg.Key != "" { |
| + return nil, errors.New("abono: Config.Sandbox and Config.Key are exclusive; the sandbox never touches a real account") |
| + } |
| + if !loopback(cfg.Origin) { |
| + return nil, errors.New("abono: Config.Sandbox needs a loopback Origin (localhost, *.localhost, 127.0.0.1 or [::1]), got " + quote(cfg.Origin)) |
| + } |
| + } |
| + |
| + if cfg.Now == nil { |
| + cfg.Now = time.Now |
| + } |
| + if cfg.Logger == nil { |
| + cfg.Logger = slog.Default() |
| + } |
| + |
| + s := &Service{cfg: cfg} |
| + if cfg.Key != "" { |
| + s.stripe = &stripeapi.Client{Key: cfg.Key, Base: cfg.StripeBase} |
| + } |
| + 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 |
| +// way: a comp needs no Stripe. |
| +func (s *Service) On() bool { |
| + return s != nil && s.stripe != nil |
| +} |
| + |
| +// now is the one clock the service reads, in UTC. UTC is not cosmetic: |
| +// timestamps reach SQLite as time.Time.String(), which appends a monotonic |
| +// reading to a time that still carries one, and a stored value with that |
| +// suffix compares wrongly as text. time.Now().UTC() strips it. |
| +func (s *Service) now() time.Time { return s.cfg.Now().UTC() } |
| + |
| +// tx runs fn in one transaction. Inside fn use only tx: a statement on the |
| +// service's own handle does not join the transaction, queues behind the |
| +// single writer connection this one is holding, and hangs rather than |
| +// errors. |
| +func (s *Service) tx(ctx context.Context, fn func(tx *gorm.DB) error) error { |
| + return s.cfg.DB.WithContext(ctx).Transaction(fn) |
| +} |
| + |
| +// changed tells the app that subject's holdings changed, inside tx. A |
| +// payment not yet tied to anyone (subject "") has nobody to tell. |
| +func (s *Service) changed(ctx context.Context, tx *gorm.DB, subject string) error { |
| + if s.cfg.OnChange == nil || subject == "" { |
| + return nil |
| + } |
| + return s.cfg.OnChange(ctx, tx, subject) |
| +} |
| + |
| +// loopback reports whether origin is an http(s) address on this machine: |
| +// localhost, anything.localhost, or a loopback IP. |
| +func loopback(origin string) bool { |
| + u, err := url.Parse(origin) |
| + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { |
| + return false |
| + } |
| + host := strings.ToLower(u.Hostname()) |
| + if host == "localhost" || strings.HasSuffix(host, ".localhost") { |
| + return true |
| + } |
| + ip := net.ParseIP(host) |
| + return ip != nil && ip.IsLoopback() |
| +} |
| + |
| +func quote(s string) string { return `"` + s + `"` } |
diff --git a/abono_test.go b/abono_test.go| new file mode 100644 |
| index 0000000..5928684 |
| --- /dev/null |
| +++ b/abono_test.go |
| @@ -0,0 +1,177 @@ |
| +package abono_test |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/migrate" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| + |
| + "amadan.net/rastrillo/abono" |
| +) |
| + |
| +// t0 is the fixed "now" the service tests run at. |
| +var t0 = time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC) |
| + |
| +// newService is a service over a fresh database with abono's schema |
| +// applied, its clock fixed at *now. cfg's DB and Now are filled in. |
| +func newService(t *testing.T, now *time.Time, cfg abono.Config) (*abono.Service, *testDB) { |
| + t.Helper() |
| + d := openDB(t) |
| + if _, err := migrate.Apply(context.Background(), d, |
| + migrate.Merge(sessions.Schema, abono.Schema)); err != nil { |
| + t.Fatalf("migrate.Apply: %v", err) |
| + } |
| + cfg.DB = d.G |
| + cfg.Now = func() time.Time { return *now } |
| + s, err := abono.New(cfg) |
| + if err != nil { |
| + t.Fatalf("abono.New: %v", err) |
| + } |
| + return s, &testDB{t: t, d: d} |
| +} |
| + |
| +// testDB is a database with a few raw helpers, for setting up rows the |
| +// service has no method to write and reading back what it wrote. |
| +type testDB struct { |
| + t *testing.T |
| + d *db.DB |
| +} |
| + |
| +func (x *testDB) exec(query string, args ...any) { |
| + x.t.Helper() |
| + if _, err := x.d.Writer().Exec(query, args...); err != nil { |
| + x.t.Fatalf("%s: %v", query, err) |
| + } |
| +} |
| + |
| +// dump runs query and returns each row as its columns joined by "|" |
| +// (NULL as ""), one row per line. |
| +func (x *testDB) dump(query string, args ...any) string { |
| + x.t.Helper() |
| + rows, err := x.d.Writer().Query(query, args...) |
| + if err != nil { |
| + x.t.Fatalf("%s: %v", query, err) |
| + } |
| + defer rows.Close() |
| + cols, _ := rows.Columns() |
| + var out strings.Builder |
| + for rows.Next() { |
| + vals := make([]any, len(cols)) |
| + ptrs := make([]any, len(cols)) |
| + for i := range vals { |
| + ptrs[i] = &vals[i] |
| + } |
| + if err := rows.Scan(ptrs...); err != nil { |
| + x.t.Fatalf("%s: %v", query, err) |
| + } |
| + for i, v := range vals { |
| + if i > 0 { |
| + out.WriteString("|") |
| + } |
| + if v != nil { |
| + fmt.Fprint(&out, v) |
| + } |
| + } |
| + out.WriteString("\n") |
| + } |
| + if err := rows.Err(); err != nil { |
| + x.t.Fatalf("%s: %v", query, err) |
| + } |
| + return out.String() |
| +} |
| + |
| +func TestNewRefusesHalfAConfiguration(t *testing.T) { |
| + d := openDB(t) |
| + |
| + for name, cfg := range map[string]abono.Config{ |
| + "a key without secrets": {DB: d.G, Key: "rk_test_1"}, |
| + "secrets without a key": {DB: d.G, WebhookSecrets: []string{"whsec_1"}}, |
| + "a blank secret": {DB: d.G, Key: "rk_test_1", WebhookSecrets: []string{""}}, |
| + "no database": {}, |
| + } { |
| + if s, err := abono.New(cfg); err == nil { |
| + t.Errorf("%s: New succeeded (On=%v), want an error", name, s.On()) |
| + } |
| + } |
| + |
| + s, err := abono.New(abono.Config{DB: d.G}) |
| + if err != nil { |
| + t.Fatalf("New with nothing configured: %v", err) |
| + } |
| + if s.On() { |
| + t.Error("On() with nothing configured, want false") |
| + } |
| + |
| + s, err = abono.New(abono.Config{DB: d.G, Key: "rk_test_1", WebhookSecrets: []string{"whsec_1", "whsec_2"}}) |
| + if err != nil { |
| + t.Fatalf("New with a key and secrets: %v", err) |
| + } |
| + if !s.On() { |
| + t.Error("On() with a key and secrets, want true") |
| + } |
| + |
| + var nilService *abono.Service |
| + if nilService.On() { |
| + t.Error("a nil *Service reports On()") |
| + } |
| +} |
| + |
| +func TestNewRefusesSandboxOffLoopback(t *testing.T) { |
| + d := openDB(t) |
| + |
| + for _, origin := range []string{"https://example.com", "http://192.168.1.5:8080", "http://localhost.example.com", ""} { |
| + if _, err := abono.New(abono.Config{DB: d.G, Sandbox: true, Origin: origin}); err == nil { |
| + t.Errorf("Sandbox with Origin %q succeeded, want an error", origin) |
| + } |
| + } |
| + for _, origin := range []string{"http://localhost:8080", "http://app.localhost", "http://127.0.0.1:3000", "http://[::1]:3000"} { |
| + s, err := abono.New(abono.Config{DB: d.G, Sandbox: true, Origin: origin}) |
| + if err != nil { |
| + 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 _, err := abono.New(abono.Config{DB: d.G, Sandbox: true, Origin: "http://localhost:8080", Key: "rk_test_1"}); err == nil { |
| + t.Error("Sandbox with a Key succeeded, want an error") |
| + } |
| +} |
| + |
| +func TestParsePlans(t *testing.T) { |
| + plans, err := abono.ParsePlans("annual=price_A|price_old1|price_old2, monthly=price_B") |
| + if err != nil { |
| + t.Fatalf("ParsePlans: %v", err) |
| + } |
| + if len(plans) != 2 || |
| + plans[0].Plan != "annual" || plans[0].Sold != "price_A" || |
| + strings.Join(plans[0].Legacy, ",") != "price_old1,price_old2" || |
| + plans[1].Plan != "monthly" || plans[1].Sold != "price_B" || len(plans[1].Legacy) != 0 { |
| + t.Fatalf("plans = %+v", plans) |
| + } |
| + |
| + if plans, err := abono.ParsePlans(""); err != nil || len(plans) != 0 { |
| + t.Errorf("empty: %+v %v", plans, err) |
| + } |
| + if plans, err := abono.ParsePlans("a=p1\nb=p2"); err != nil || len(plans) != 2 { |
| + t.Errorf("newline separated: %+v %v", plans, err) |
| + } |
| + |
| + for _, bad := range []string{ |
| + "annual", "Annual=price_A", "annual=", "annual=price_A||price_B", "a b=price", |
| + "=price_A", "annual=price_A,annual=price_B", |
| + "annual=price_A,monthly=price_A", // one price in two plans |
| + "annual=price_A|price_A", // one price twice in a plan |
| + "annual=price_A,monthly=price_B|price_A", |
| + } { |
| + if _, err := abono.ParsePlans(bad); err == nil { |
| + t.Errorf("%q parsed, want an error", bad) |
| + } |
| + } |
| +} |
diff --git a/comps.go b/comps.go| new file mode 100644 |
| index 0000000..2bcf9cd |
| --- /dev/null |
| +++ b/comps.go |
| @@ -0,0 +1,78 @@ |
| +package abono |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| +) |
| + |
| +// Comp statuses, as the schema's CHECK allows them. |
| +const ( |
| + compActive = "active" |
| + compCanceled = "canceled" |
| +) |
| + |
| +type compRow struct { |
| + ID int64 |
| + Subject string |
| + Plan string |
| + Status string |
| + EndsAt *time.Time |
| + Note string |
| + CreatedAt time.Time |
| + UpdatedAt time.Time |
| +} |
| + |
| +func (compRow) TableName() string { return "abono_comps" } |
| + |
| +// Comp gives subject the plan without payment, until endsAt or for good |
| +// when endsAt is nil, and returns the comp's id. It is a comp, not a |
| +// grant, so it is never confused with "grants access". OnChange runs in the |
| +// same transaction, and its error undoes the comp. |
| +func (s *Service) Comp(ctx context.Context, subject, plan string, endsAt *time.Time, note string) (int64, error) { |
| + if subject == "" || plan == "" { |
| + return 0, errors.New("abono: Comp needs a subject and a plan") |
| + } |
| + now := s.now() |
| + row := compRow{Subject: subject, Plan: plan, Status: compActive, Note: note, CreatedAt: now, UpdatedAt: now} |
| + if endsAt != nil { |
| + e := endsAt.UTC() |
| + row.EndsAt = &e |
| + } |
| + err := s.tx(ctx, func(tx *gorm.DB) error { |
| + if err := tx.Create(&row).Error; err != nil { |
| + return err |
| + } |
| + return s.changed(ctx, tx, subject) |
| + }) |
| + if err != nil { |
| + return 0, err |
| + } |
| + return row.ID, nil |
| +} |
| + |
| +// EndComp ends a comp now. Ending one already ended changes nothing and |
| +// tells nobody. OnChange runs in the same transaction, and its error leaves |
| +// the comp as it was. |
| +func (s *Service) EndComp(ctx context.Context, id int64) error { |
| + return s.tx(ctx, func(tx *gorm.DB) error { |
| + var row compRow |
| + switch err := tx.Where("id = ?", id).Take(&row).Error; { |
| + case errors.Is(err, gorm.ErrRecordNotFound): |
| + return fmt.Errorf("abono: comp %d: %w", id, ErrNotFound) |
| + case err != nil: |
| + return err |
| + } |
| + if row.Status == compCanceled { |
| + return nil |
| + } |
| + if err := tx.Exec(`UPDATE abono_comps SET status = ?, updated_at = ? WHERE id = ?`, |
| + compCanceled, s.now(), id).Error; err != nil { |
| + return err |
| + } |
| + return s.changed(ctx, tx, row.Subject) |
| + }) |
| +} |
diff --git a/comps_test.go b/comps_test.go| new file mode 100644 |
| index 0000000..35a7dfb |
| --- /dev/null |
| +++ b/comps_test.go |
| @@ -0,0 +1,101 @@ |
| +package abono_test |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| + |
| + "amadan.net/rastrillo/abono" |
| +) |
| + |
| +func TestCompAndEndCompCallOnChangeInTheSameTransaction(t *testing.T) { |
| + now := t0 |
| + var calls []string |
| + var seen int64 = -1 |
| + var failWith error |
| + s, db := newService(t, &now, abono.Config{ |
| + OnChange: func(ctx context.Context, tx *gorm.DB, subject string) error { |
| + calls = append(calls, subject) |
| + // Same transaction: the comp written a moment ago is visible |
| + // here, though it is not committed yet. |
| + if err := tx.Table("abono_comps").Where("subject = ? AND status = 'active'", subject).Count(&seen).Error; err != nil { |
| + return err |
| + } |
| + return failWith |
| + }, |
| + }) |
| + ctx := context.Background() |
| + |
| + id, err := s.Comp(ctx, "s1", "annual", nil, "for the editor") |
| + if err != nil { |
| + t.Fatalf("Comp: %v", err) |
| + } |
| + if id <= 0 { |
| + t.Errorf("Comp id = %d", id) |
| + } |
| + if len(calls) != 1 || calls[0] != "s1" || seen != 1 { |
| + t.Errorf("OnChange calls = %v, active comps seen inside = %d; want [s1] and 1", calls, seen) |
| + } |
| + if got := db.dump("SELECT subject, plan, status, ends_at, note FROM abono_comps"); got != "s1|annual|active||for the editor\n" { |
| + t.Errorf("comps = %q", got) |
| + } |
| + |
| + // A hook error rolls Comp back. |
| + failWith = errors.New("hook failed") |
| + if _, err := s.Comp(ctx, "s2", "annual", nil, ""); !errors.Is(err, failWith) { |
| + t.Errorf("Comp with a failing hook: %v, want the hook's error", err) |
| + } |
| + if got := db.dump("SELECT subject FROM abono_comps WHERE subject = 's2'"); got != "" { |
| + t.Errorf("a comp survived its hook's failure: %q", got) |
| + } |
| + |
| + // And rolls EndComp back. |
| + if err := s.EndComp(ctx, id); !errors.Is(err, failWith) { |
| + t.Errorf("EndComp with a failing hook: %v, want the hook's error", err) |
| + } |
| + if got := db.dump("SELECT status FROM abono_comps WHERE id = ?", id); got != "active\n" { |
| + t.Errorf("status after a rolled-back EndComp = %q", got) |
| + } |
| + |
| + failWith = nil |
| + calls = nil |
| + now = t0.Add(time.Hour) |
| + if err := s.EndComp(ctx, id); err != nil { |
| + t.Fatalf("EndComp: %v", err) |
| + } |
| + if len(calls) != 1 || calls[0] != "s1" { |
| + t.Errorf("OnChange calls after EndComp = %v", calls) |
| + } |
| + if got := db.dump("SELECT status FROM abono_comps WHERE id = ?", id); got != "canceled\n" { |
| + t.Errorf("status = %q", got) |
| + } |
| + if has, err := s.HasPlan(ctx, "s1", "annual"); err != nil || has { |
| + t.Errorf("HasPlan after EndComp = %v, %v", has, err) |
| + } |
| + |
| + // Ending it again changes nothing, so tells nobody. |
| + calls = nil |
| + if err := s.EndComp(ctx, id); err != nil { |
| + t.Errorf("EndComp again: %v", err) |
| + } |
| + if len(calls) != 0 { |
| + t.Errorf("EndComp on an ended comp called OnChange: %v", calls) |
| + } |
| + if err := s.EndComp(ctx, id+100); !errors.Is(err, abono.ErrNotFound) { |
| + t.Errorf("EndComp of a missing comp: %v, want ErrNotFound", err) |
| + } |
| +} |
| + |
| +func TestCompRefusesNoSubjectOrPlan(t *testing.T) { |
| + now := t0 |
| + s, _ := newService(t, &now, abono.Config{}) |
| + if _, err := s.Comp(context.Background(), "", "annual", nil, ""); err == nil { |
| + t.Error("Comp with no subject succeeded") |
| + } |
| + if _, err := s.Comp(context.Background(), "s1", "", nil, ""); err == nil { |
| + t.Error("Comp with no plan succeeded") |
| + } |
| +} |
diff --git a/describe.go b/describe.go| new file mode 100644 |
| index 0000000..89cd966 |
| --- /dev/null |
| +++ b/describe.go |
| @@ -0,0 +1,72 @@ |
| +package abono |
| + |
| +import "time" |
| + |
| +// The keys of a Status, for the app to translate. Tones are status-pill's. |
| +const ( |
| + StatusActive = "abono.status.active" |
| + StatusRenews = "abono.status.renews" |
| + StatusCancelling = "abono.status.cancelling" |
| + StatusFailing = "abono.status.failing" |
| + StatusEnded = "abono.status.ended" |
| + StatusComp = "abono.status.comp" |
| + StatusCompUntil = "abono.status.comp_until" |
| +) |
| + |
| +// Status is the one line a holding reads as: a message key, a tone, and |
| +// the date the key's sentence mentions, when it has one. |
| +type Status struct { |
| + Key string |
| + Tone string // positive, warning, negative or neutral |
| + Date *time.Time |
| +} |
| + |
| +// Describe is what to say of h at now. It applies the same holding rule as |
| +// Holdings, at the now it is given, so a stale Holding cannot say "active" |
| +// of access that has ended. |
| +// |
| +// active grants, no date known |
| +// renews active or trialing, with a period end, not cancelling: Date is the period end |
| +// cancelling cancel_at_period_end and granting: Date is the period end |
| +// failing past_due and granting: Date is the day access stops, the period end plus 30 days |
| +// ended does not grant |
| +// comp a comp with no end |
| +// comp_until a comp with an end: Date is ends_at |
| +// |
| +// A past_due subscription that is also cancelling reads as failing: the |
| +// payment problem is what the reader can act on, and the date it gives is |
| +// the one access really stops on. |
| +func Describe(h Holding, now time.Time) Status { |
| + granting, _ := h.evaluate(now) |
| + if !granting { |
| + return Status{Key: StatusEnded, Tone: "neutral"} |
| + } |
| + if h.Source == SourceComp { |
| + if h.Until == nil { |
| + return Status{Key: StatusComp, Tone: "positive"} |
| + } |
| + return Status{Key: StatusCompUntil, Tone: "positive", Date: copyTime(h.Until)} |
| + } |
| + switch { |
| + case h.Status == "past_due": |
| + var stops *time.Time |
| + if h.Until != nil { |
| + t := h.Until.Add(backstopWindow) |
| + stops = &t |
| + } |
| + return Status{Key: StatusFailing, Tone: "warning", Date: stops} |
| + case h.CancelAtPeriodEnd: |
| + return Status{Key: StatusCancelling, Tone: "warning", Date: copyTime(h.Until)} |
| + case h.Until != nil: |
| + return Status{Key: StatusRenews, Tone: "positive", Date: copyTime(h.Until)} |
| + } |
| + return Status{Key: StatusActive, Tone: "positive"} |
| +} |
| + |
| +func copyTime(t *time.Time) *time.Time { |
| + if t == nil { |
| + return nil |
| + } |
| + c := *t |
| + return &c |
| +} |
diff --git a/describe_test.go b/describe_test.go| new file mode 100644 |
| index 0000000..23df428 |
| --- /dev/null |
| +++ b/describe_test.go |
| @@ -0,0 +1,73 @@ |
| +package abono_test |
| + |
| +import ( |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/rastrillo/abono" |
| +) |
| + |
| +func TestDescribe(t *testing.T) { |
| + now := t0 |
| + end := now.Add(10 * day) |
| + past := now.Add(-5 * day) |
| + ptr := func(v time.Time) *time.Time { return &v } |
| + |
| + for _, c := range []struct { |
| + name string |
| + h abono.Holding |
| + key string |
| + tone string |
| + date *time.Time |
| + }{ |
| + {"active, no date known", |
| + abono.Holding{Source: abono.SourceSubscription, Status: "active"}, |
| + "abono.status.active", "positive", nil}, |
| + {"renews", |
| + abono.Holding{Source: abono.SourceSubscription, Status: "active", Until: &end}, |
| + "abono.status.renews", "positive", &end}, |
| + {"trialing renews", |
| + abono.Holding{Source: abono.SourceSubscription, Status: "trialing", Until: &end}, |
| + "abono.status.renews", "positive", &end}, |
| + {"cancelling", |
| + abono.Holding{Source: abono.SourceSubscription, Status: "active", Until: &end, CancelAtPeriodEnd: true}, |
| + "abono.status.cancelling", "warning", &end}, |
| + {"failing, before the period end", |
| + abono.Holding{Source: abono.SourceSubscription, Status: "past_due", Until: &end}, |
| + "abono.status.failing", "warning", ptr(end.Add(30 * day))}, |
| + {"failing, held by the backstop", |
| + abono.Holding{Source: abono.SourceSubscription, Status: "past_due", Until: &past}, |
| + "abono.status.failing", "warning", ptr(past.Add(30 * day))}, |
| + {"failing and cancelling reads as failing", |
| + abono.Holding{Source: abono.SourceSubscription, Status: "past_due", Until: &end, CancelAtPeriodEnd: true}, |
| + "abono.status.failing", "warning", ptr(end.Add(30 * day))}, |
| + {"ended, canceled", |
| + abono.Holding{Source: abono.SourceSubscription, Status: "canceled", Until: &past}, |
| + "abono.status.ended", "neutral", nil}, |
| + {"ended, past_due beyond the backstop", |
| + abono.Holding{Source: abono.SourceSubscription, Status: "past_due", Until: ptr(now.Add(-31 * day))}, |
| + "abono.status.ended", "neutral", nil}, |
| + {"ended, a comp past its end", |
| + abono.Holding{Source: abono.SourceComp, Status: "active", Until: &past}, |
| + "abono.status.ended", "neutral", nil}, |
| + {"ended, a canceled comp", |
| + abono.Holding{Source: abono.SourceComp, Status: "canceled"}, |
| + "abono.status.ended", "neutral", nil}, |
| + {"comp", |
| + abono.Holding{Source: abono.SourceComp, Status: "active"}, |
| + "abono.status.comp", "positive", nil}, |
| + {"comp until", |
| + abono.Holding{Source: abono.SourceComp, Status: "active", Until: &end}, |
| + "abono.status.comp_until", "positive", &end}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + got := abono.Describe(c.h, now) |
| + if got.Key != c.key || got.Tone != c.tone { |
| + t.Errorf("Describe = %s/%s, want %s/%s", got.Key, got.Tone, c.key, c.tone) |
| + } |
| + if (got.Date == nil) != (c.date == nil) || (got.Date != nil && !got.Date.Equal(*c.date)) { |
| + t.Errorf("Date = %v, want %v", got.Date, c.date) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/go.mod b/go.mod| index bf05534..1584a21 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -2,7 +2,10 @@ module amadan.net/rastrillo/abono |
| go 1.25.0 |
| -require amadan.net/rastrillo/rastrillo v0.28.1-0.20261022095550-8fbde25ba6f1 |
| +require ( |
| + amadan.net/rastrillo/rastrillo v0.28.1-0.20261022095550-8fbde25ba6f1 |
| + gorm.io/gorm v1.31.2 |
| +) |
| require ( |
| github.com/dustin/go-humanize v1.0.1 // indirect |
| @@ -14,7 +17,6 @@ require ( |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| golang.org/x/sys v0.46.0 // indirect |
| golang.org/x/text v0.39.0 // indirect |
| - gorm.io/gorm v1.31.2 // indirect |
| gorm.io/plugin/dbresolver v1.6.2 // indirect |
| modernc.org/libc v1.74.1 // indirect |
| modernc.org/mathutil v1.7.1 // indirect |
diff --git a/holdings.go b/holdings.go| new file mode 100644 |
| index 0000000..e516e56 |
| --- /dev/null |
| +++ b/holdings.go |
| @@ -0,0 +1,187 @@ |
| +package abono |
| + |
| +import ( |
| + "context" |
| + "sort" |
| + "time" |
| +) |
| + |
| +// backstopWindow is how long past a paid period's end a past_due subscription |
| +// still grants: Stripe lets an account leave a subscription past_due for |
| +// good once its retries end, so abono stops holding it 30 days after the |
| +// period it was paid to, whatever the account's setting. |
| +const backstopWindow = 30 * 24 * time.Hour |
| + |
| +// Source says where a holding came from. |
| +type Source string |
| + |
| +const ( |
| + // SourceSubscription is a Stripe subscription. |
| + SourceSubscription Source = "subscription" |
| + // SourceComp is a comp: given by an operator, not paid for. |
| + SourceComp Source = "comp" |
| +) |
| + |
| +// Holding is one plan a subject has, or had: what Stripe or an operator |
| +// says of it, and whether it grants access right now. Grants is the |
| +// answer; the rest is for showing it. |
| +type Holding struct { |
| + Plan string |
| + Source Source |
| + // Status is Stripe's subscription status, or a comp's: active or |
| + // canceled. |
| + Status string |
| + // Until is the paid period's end for a subscription, a comp's ends_at; |
| + // nil when there is none. |
| + Until *time.Time |
| + CancelAtPeriodEnd bool |
| + // Grants is whether this holding gives access at the moment Holdings |
| + // was asked. |
| + Grants bool |
| + // Backstop is set on a past_due subscription that grants only because |
| + // of the 30-day backstop: its paid period is over. The owner's screen |
| + // flags these. |
| + Backstop bool |
| + |
| + SubscriptionID string // set for SourceSubscription |
| + CompID int64 // set for SourceComp |
| +} |
| + |
| +// grants is the one holding rule for a Stripe subscription, used by |
| +// Holdings and by Describe, so a screen can never say what access does not. |
| +// |
| +// active, trialing grants |
| +// past_due grants while now < periodEnd + 30 days |
| +// canceled, unpaid, incomplete, |
| +// incomplete_expired, paused, anything |
| +// Stripe adds later does not |
| +// |
| +// backstop is true for a past_due subscription held only by the 30-day |
| +// window, that is once now has reached periodEnd. |
| +func grants(status string, periodEnd *time.Time, now time.Time) (grants, backstop bool) { |
| + switch status { |
| + case "active", "trialing": |
| + return true, false |
| + case "past_due": |
| + if periodEnd == nil { |
| + // Stripe is retrying the card and we have no period end to count |
| + // the 30 days from. Nothing says the access should have stopped, so |
| + // it grants, and it is not flagged as held by the backstop because |
| + // there is no backstop to hold it. |
| + return true, false |
| + } |
| + if !now.Before(periodEnd.Add(backstopWindow)) { |
| + return false, false |
| + } |
| + return true, !now.Before(*periodEnd) |
| + } |
| + return false, false |
| +} |
| + |
| +// compGrants is the rule for a comp: active, and before its end if it has |
| +// one. A comp whose end is now is over. |
| +func compGrants(status string, endsAt *time.Time, now time.Time) bool { |
| + if status != compActive { |
| + return false |
| + } |
| + return endsAt == nil || now.Before(*endsAt) |
| +} |
| + |
| +// evaluate is the holding rule applied to h at now. |
| +func (h Holding) evaluate(now time.Time) (ok, held bool) { |
| + if h.Source == SourceComp { |
| + return compGrants(h.Status, h.Until, now), false |
| + } |
| + return grants(h.Status, h.Until, now) |
| +} |
| + |
| +type subscriptionRow struct { |
| + StripeSubscriptionID string |
| + Subject string |
| + Plan string |
| + Status string |
| + CurrentPeriodEnd *time.Time |
| + CancelAtPeriodEnd bool |
| + CreatedAt time.Time |
| +} |
| + |
| +func (subscriptionRow) TableName() string { return "abono_subscriptions" } |
| + |
| +// Holdings is what this subject has, newest first: subscriptions and comps |
| +// together, the ones that no longer grant included (they read as ended). |
| +// Subscriptions whose price no offer recognises (plan "") and payments not |
| +// yet tied to a subject are left out: neither can grant. |
| +func (s *Service) Holdings(ctx context.Context, subject string) ([]Holding, error) { |
| + if subject == "" { |
| + return nil, nil |
| + } |
| + g := s.cfg.DB.WithContext(ctx) |
| + var subs []subscriptionRow |
| + if err := g.Where("subject = ? AND plan <> ''", subject).Find(&subs).Error; err != nil { |
| + return nil, err |
| + } |
| + var comps []compRow |
| + if err := g.Where("subject = ?", subject).Find(&comps).Error; err != nil { |
| + return nil, err |
| + } |
| + |
| + now := s.now() |
| + type dated struct { |
| + Holding |
| + created time.Time |
| + } |
| + var all []dated |
| + for _, r := range subs { |
| + h := Holding{ |
| + Plan: r.Plan, Source: SourceSubscription, Status: r.Status, |
| + Until: utc(r.CurrentPeriodEnd), CancelAtPeriodEnd: r.CancelAtPeriodEnd, |
| + SubscriptionID: r.StripeSubscriptionID, |
| + } |
| + h.Grants, h.Backstop = h.evaluate(now) |
| + all = append(all, dated{h, r.CreatedAt}) |
| + } |
| + for _, r := range comps { |
| + h := Holding{Plan: r.Plan, Source: SourceComp, Status: r.Status, Until: utc(r.EndsAt), CompID: r.ID} |
| + h.Grants, h.Backstop = h.evaluate(now) |
| + all = append(all, dated{h, r.CreatedAt}) |
| + } |
| + sort.SliceStable(all, func(i, j int) bool { |
| + if !all[i].created.Equal(all[j].created) { |
| + return all[i].created.After(all[j].created) |
| + } |
| + // Same instant: the later comp, then subscriptions, in a fixed order. |
| + return all[i].CompID > all[j].CompID |
| + }) |
| + out := make([]Holding, len(all)) |
| + for i, d := range all { |
| + out[i] = d.Holding |
| + } |
| + return out, nil |
| +} |
| + |
| +// HasPlan reports whether subject holds plan right now, by a subscription |
| +// or a comp. (The design calls this Grants; it is renamed so it cannot be |
| +// confused with a comp.) |
| +func (s *Service) HasPlan(ctx context.Context, subject, plan string) (bool, error) { |
| + if plan == "" { |
| + return false, nil |
| + } |
| + hs, err := s.Holdings(ctx, subject) |
| + if err != nil { |
| + return false, err |
| + } |
| + for _, h := range hs { |
| + if h.Plan == plan && h.Grants { |
| + return true, nil |
| + } |
| + } |
| + return false, nil |
| +} |
| + |
| +func utc(t *time.Time) *time.Time { |
| + if t == nil { |
| + return nil |
| + } |
| + u := t.UTC() |
| + return &u |
| +} |
diff --git a/holdings_test.go b/holdings_test.go| new file mode 100644 |
| index 0000000..eb328c4 |
| --- /dev/null |
| +++ b/holdings_test.go |
| @@ -0,0 +1,164 @@ |
| +package abono_test |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/rastrillo/abono" |
| +) |
| + |
| +const day = 24 * time.Hour |
| + |
| +// TestHoldingTable is every row of the design's section 3.3 for |
| +// subscriptions and comps, with the edges of each time rule. |
| +func TestHoldingTable(t *testing.T) { |
| + end := t0 // the paid period (or comp) ends at t0 in every case |
| + at := func(d time.Duration) time.Time { return end.Add(d) } |
| + ptr := func(v time.Time) *time.Time { return &v } |
| + |
| + type tc struct { |
| + name string |
| + source abono.Source |
| + status string |
| + end *time.Time // current_period_end, or a comp's ends_at |
| + cancel bool |
| + now time.Time |
| + grants bool |
| + backstop bool |
| + } |
| + cases := []tc{ |
| + {name: "active", status: "active", end: ptr(end), now: at(-day), grants: true}, |
| + {name: "active, period elapsed (renewal webhook late)", status: "active", end: ptr(end), now: at(2 * day), grants: true}, |
| + {name: "active, no period end", status: "active", now: at(0), grants: true}, |
| + {name: "active, cancelling, before the end", status: "active", end: ptr(end), cancel: true, now: at(-day), grants: true}, |
| + {name: "trialing", status: "trialing", end: ptr(end), now: at(-day), grants: true}, |
| + {name: "past_due before the period end", status: "past_due", end: ptr(end), now: at(-time.Second), grants: true}, |
| + {name: "past_due at the period end", status: "past_due", end: ptr(end), now: at(0), grants: true, backstop: true}, |
| + {name: "past_due, 29 days past", status: "past_due", end: ptr(end), now: at(29 * day), grants: true, backstop: true}, |
| + {name: "past_due, one second before the backstop ends", status: "past_due", end: ptr(end), now: at(30*day - time.Second), grants: true, backstop: true}, |
| + {name: "past_due at the backstop", status: "past_due", end: ptr(end), now: at(30 * day), grants: false}, |
| + {name: "past_due past the backstop", status: "past_due", end: ptr(end), now: at(31 * day), grants: false}, |
| + {name: "past_due with no period end", status: "past_due", now: at(400 * day), grants: true}, |
| + {name: "canceled", status: "canceled", end: ptr(end), now: at(-day), grants: false}, |
| + {name: "unpaid", status: "unpaid", end: ptr(end), now: at(-day), grants: false}, |
| + {name: "incomplete", status: "incomplete", end: ptr(end), now: at(-day), grants: false}, |
| + {name: "incomplete_expired", status: "incomplete_expired", end: ptr(end), now: at(-day), grants: false}, |
| + {name: "paused", status: "paused", end: ptr(end), now: at(-day), grants: false}, |
| + {name: "a status Stripe may add later", status: "future_status", end: ptr(end), now: at(-day), grants: false}, |
| + |
| + {name: "comp, no end", source: abono.SourceComp, status: "active", now: at(1000 * day), grants: true}, |
| + {name: "comp before its end", source: abono.SourceComp, status: "active", end: ptr(end), now: at(-time.Second), grants: true}, |
| + {name: "comp at its end", source: abono.SourceComp, status: "active", end: ptr(end), now: at(0), grants: false}, |
| + {name: "comp after its end", source: abono.SourceComp, status: "active", end: ptr(end), now: at(day), grants: false}, |
| + {name: "comp canceled", source: abono.SourceComp, status: "canceled", now: at(-day), grants: false}, |
| + {name: "comp canceled before its end", source: abono.SourceComp, status: "canceled", end: ptr(end), now: at(-day), grants: false}, |
| + } |
| + |
| + for i, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + now := c.now |
| + s, db := newService(t, &now, abono.Config{}) |
| + ctx := context.Background() |
| + sub := fmt.Sprintf("s%d", i) |
| + if c.source == abono.SourceComp { |
| + db.exec(`INSERT INTO abono_comps (subject, plan, status, ends_at, created_at, updated_at) VALUES (?, 'annual', ?, ?, ?, ?)`, |
| + sub, c.status, c.end, t0, t0) |
| + } else { |
| + db.exec(`INSERT INTO abono_subscriptions (stripe_subscription_id, stripe_customer_id, subject, plan, stripe_price_id, status, current_period_end, cancel_at_period_end, created_at, updated_at) |
| + VALUES ('sub_1', 'cus_1', ?, 'annual', 'price_A', ?, ?, ?, ?, ?)`, |
| + sub, c.status, c.end, c.cancel, t0, t0) |
| + } |
| + |
| + hs, err := s.Holdings(ctx, sub) |
| + if err != nil { |
| + t.Fatalf("Holdings: %v", err) |
| + } |
| + if len(hs) != 1 { |
| + t.Fatalf("Holdings = %+v, want one", hs) |
| + } |
| + h := hs[0] |
| + wantSource := c.source |
| + if wantSource == "" { |
| + wantSource = abono.SourceSubscription |
| + } |
| + if h.Source != wantSource || h.Plan != "annual" || h.Status != c.status || h.CancelAtPeriodEnd != c.cancel { |
| + t.Errorf("holding = %+v", h) |
| + } |
| + if h.Grants != c.grants || h.Backstop != c.backstop { |
| + t.Errorf("Grants, Backstop = %v, %v; want %v, %v", h.Grants, h.Backstop, c.grants, c.backstop) |
| + } |
| + if (h.Until == nil) != (c.end == nil) || (h.Until != nil && !h.Until.Equal(*c.end)) { |
| + t.Errorf("Until = %v, want %v", h.Until, c.end) |
| + } |
| + if h.Source == abono.SourceSubscription && h.SubscriptionID != "sub_1" || h.Source == abono.SourceComp && h.CompID == 0 { |
| + t.Errorf("ids missing: %+v", h) |
| + } |
| + if has, err := s.HasPlan(ctx, sub, "annual"); err != nil || has != c.grants { |
| + t.Errorf("HasPlan = %v, %v; want %v", has, err, c.grants) |
| + } |
| + if has, _ := s.HasPlan(ctx, sub, "monthly"); has { |
| + t.Error("HasPlan of a plan not held") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestUnrecognisedNeverGrants(t *testing.T) { |
| + now := t0 |
| + s, db := newService(t, &now, abono.Config{}) |
| + ctx := context.Background() |
| + future := t0.Add(30 * day) |
| + |
| + // A price no offer lists: plan = ''. |
| + db.exec(`INSERT INTO abono_subscriptions (stripe_subscription_id, stripe_customer_id, subject, plan, stripe_price_id, status, current_period_end, created_at, updated_at) |
| + VALUES ('sub_x', 'cus_1', 's1', '', 'price_unknown', 'active', ?, ?, ?)`, future, t0, t0) |
| + // Paid for, not yet claimed: subject = ''. |
| + db.exec(`INSERT INTO abono_subscriptions (stripe_subscription_id, stripe_customer_id, subject, plan, stripe_price_id, status, current_period_end, email, created_at, updated_at) |
| + VALUES ('sub_u', 'cus_2', '', 'annual', 'price_A', 'active', ?, 'a@example.com', ?, ?)`, future, t0, t0) |
| + |
| + hs, err := s.Holdings(ctx, "s1") |
| + if err != nil || len(hs) != 0 { |
| + t.Errorf("Holdings(s1) = %+v, %v; want none", hs, err) |
| + } |
| + if has, err := s.HasPlan(ctx, "s1", ""); err != nil || has { |
| + t.Errorf("HasPlan(s1, \"\") = %v, %v", has, err) |
| + } |
| + if has, err := s.HasPlan(ctx, "s1", "annual"); err != nil || has { |
| + t.Errorf("HasPlan(s1, annual) = %v, %v", has, err) |
| + } |
| + hs, err = s.Holdings(ctx, "") |
| + if err != nil || len(hs) != 0 { |
| + t.Errorf("Holdings of no subject = %+v, %v; want none", hs, err) |
| + } |
| + if has, err := s.HasPlan(ctx, "", "annual"); err != nil || has { |
| + t.Errorf("HasPlan of no subject = %v, %v", has, err) |
| + } |
| +} |
| + |
| +func TestHoldingsNewestFirstAndOnlyThisSubjects(t *testing.T) { |
| + now := t0 |
| + s, db := newService(t, &now, abono.Config{}) |
| + ctx := context.Background() |
| + |
| + old, mid, recent := t0.Add(-3*day), t0.Add(-2*day), t0.Add(-day) |
| + db.exec(`INSERT INTO abono_subscriptions (stripe_subscription_id, stripe_customer_id, subject, plan, stripe_price_id, status, created_at, updated_at) |
| + VALUES ('sub_old', 'cus_1', 's1', 'monthly', 'price_B', 'canceled', ?, ?)`, old, old) |
| + db.exec(`INSERT INTO abono_subscriptions (stripe_subscription_id, stripe_customer_id, subject, plan, stripe_price_id, status, created_at, updated_at) |
| + VALUES ('sub_new', 'cus_1', 's1', 'annual', 'price_A', 'active', ?, ?)`, recent, recent) |
| + db.exec(`INSERT INTO abono_comps (subject, plan, status, created_at, updated_at) VALUES ('s1', 'friend', 'active', ?, ?)`, mid, mid) |
| + db.exec(`INSERT INTO abono_comps (subject, plan, status, created_at, updated_at) VALUES ('s2', 'friend', 'active', ?, ?)`, mid, mid) |
| + |
| + hs, err := s.Holdings(ctx, "s1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var got []string |
| + for _, h := range hs { |
| + got = append(got, h.Plan) |
| + } |
| + if fmt.Sprint(got) != "[annual friend monthly]" { |
| + t.Errorf("plans newest first = %v", got) |
| + } |
| +} |
diff --git a/offers.go b/offers.go| new file mode 100644 |
| index 0000000..39bb427 |
| --- /dev/null |
| +++ b/offers.go |
| @@ -0,0 +1,217 @@ |
| +package abono |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "regexp" |
| + "strings" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| +) |
| + |
| +// PlanSpec is one plan an app sells, as an environment variable or a seed |
| +// says it: the price sold now, and older prices whose subscriptions are the |
| +// same plan (a WordPress site's, say). |
| +type PlanSpec struct { |
| + Plan string |
| + Sold string |
| + Legacy []string |
| +} |
| + |
| +// planName is a plan's address-safe name: lower-case words joined by |
| +// hyphens, so a plan can sit in a URL. |
| +var planName = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) |
| + |
| +// ParsePlans reads Farshad's format, "annual=price_A|price_old,monthly=price_B": |
| +// the first price after = is the one sold, the rest are legacy. Plans are |
| +// separated by commas, spaces or newlines. A price may belong to only one |
| +// plan, once. |
| +func ParsePlans(s string) ([]PlanSpec, error) { |
| + var specs []PlanSpec |
| + for _, f := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' || r == '\t' || r == '\r' }) { |
| + name, prices, ok := strings.Cut(f, "=") |
| + ids := strings.Split(prices, "|") |
| + if !ok || !planName.MatchString(name) { |
| + return nil, fmt.Errorf("abono: plans: %q is not plan=price_...", f) |
| + } |
| + for _, id := range ids { |
| + if id == "" { |
| + return nil, fmt.Errorf("abono: plans: %q has an empty price", f) |
| + } |
| + } |
| + specs = append(specs, PlanSpec{Plan: name, Sold: ids[0], Legacy: ids[1:]}) |
| + } |
| + if err := validatePlans(specs); err != nil { |
| + return nil, err |
| + } |
| + return specs, nil |
| +} |
| + |
| +// validatePlans refuses a plan with no name or no sold price, a blank |
| +// legacy price, a plan named twice, and a price listed twice (in one plan or |
| +// two): a price must resolve to exactly one plan. |
| +func validatePlans(specs []PlanSpec) error { |
| + plans := map[string]bool{} |
| + prices := map[string]string{} |
| + for _, sp := range specs { |
| + if sp.Plan == "" { |
| + return errors.New("abono: plans: a plan has no name") |
| + } |
| + if plans[sp.Plan] { |
| + return fmt.Errorf("abono: plans: %q is listed twice", sp.Plan) |
| + } |
| + plans[sp.Plan] = true |
| + if sp.Sold == "" { |
| + return fmt.Errorf("abono: plans: %q has no price", sp.Plan) |
| + } |
| + for _, id := range append([]string{sp.Sold}, sp.Legacy...) { |
| + if id == "" { |
| + return fmt.Errorf("abono: plans: %q has an empty price", sp.Plan) |
| + } |
| + if other, dup := prices[id]; dup { |
| + if other == sp.Plan { |
| + return fmt.Errorf("abono: plans: price %q is listed twice in %q", id, sp.Plan) |
| + } |
| + return fmt.Errorf("abono: plans: price %q is in both %q and %q", id, other, sp.Plan) |
| + } |
| + prices[id] = sp.Plan |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// Offer is a plan on sale, with the prices that belong to it. |
| +type Offer struct { |
| + Plan string |
| + // Name is what to call the plan to a reader; the plan itself when no |
| + // name was given. |
| + Name string |
| + Prices []Price |
| +} |
| + |
| +// Price is one Stripe price of an offer. Interval, Amount and Currency are |
| +// what Stripe last said, and empty until a refresh has read them. |
| +type Price struct { |
| + ID string |
| + Sold bool |
| + Interval string |
| + Amount int64 |
| + Currency string |
| +} |
| + |
| +type offerRow struct { |
| + Plan string |
| + Name string |
| + CreatedAt time.Time |
| + ArchivedAt *time.Time |
| +} |
| + |
| +func (offerRow) TableName() string { return "abono_offers" } |
| + |
| +type priceRow struct { |
| + StripePriceID string `gorm:"primaryKey"` |
| + Plan string |
| + Sold bool |
| + Interval string |
| + Amount int64 |
| + Currency string |
| + RefreshedAt *time.Time |
| +} |
| + |
| +func (priceRow) TableName() string { return "abono_prices" } |
| + |
| +// SeedPlans makes the database match specs: each plan gets an offer and |
| +// its prices, the first (Sold) with sold=1 and the legacy ones with sold=0. |
| +// It is idempotent and it never deletes: a price dropped from the specs |
| +// stays, because subscriptions already on it must still resolve to their |
| +// plan. Seeding a plan again unarchives it, since the app just said it is |
| +// for sale. A name an operator gave an offer, and what Stripe said of a |
| +// price, are left alone. |
| +func (s *Service) SeedPlans(ctx context.Context, specs []PlanSpec) error { |
| + if err := validatePlans(specs); err != nil { |
| + return err |
| + } |
| + now := s.now() |
| + return s.tx(ctx, func(tx *gorm.DB) error { |
| + for _, sp := range specs { |
| + if err := tx.Exec( |
| + `INSERT INTO abono_offers (plan, name, created_at) VALUES (?, '', ?) |
| + ON CONFLICT(plan) DO UPDATE SET archived_at = NULL`, |
| + sp.Plan, now).Error; err != nil { |
| + return err |
| + } |
| + for i, id := range append([]string{sp.Sold}, sp.Legacy...) { |
| + if err := upsertPrice(tx, sp.Plan, id, i == 0); err != nil { |
| + return err |
| + } |
| + } |
| + } |
| + return nil |
| + }) |
| +} |
| + |
| +// upsertPrice records that priceID belongs to plan, sold or not. The |
| +// price's interval, amount and currency are not touched. |
| +func upsertPrice(tx *gorm.DB, plan, priceID string, sold bool) error { |
| + return tx.Exec( |
| + `INSERT INTO abono_prices (stripe_price_id, plan, sold) VALUES (?, ?, ?) |
| + ON CONFLICT(stripe_price_id) DO UPDATE SET plan = excluded.plan, sold = excluded.sold`, |
| + priceID, plan, sold).Error |
| +} |
| + |
| +// AddPrice adds priceID to an existing offer, or changes whether it is |
| +// sold. A price that belongs to another plan is refused: moving it would |
| +// change what its subscriptions mean. |
| +func (s *Service) AddPrice(ctx context.Context, plan, priceID string, sold bool) error { |
| + if plan == "" || priceID == "" { |
| + return errors.New("abono: AddPrice needs a plan and a price") |
| + } |
| + return s.tx(ctx, func(tx *gorm.DB) error { |
| + var n int64 |
| + if err := tx.Table("abono_offers").Where("plan = ?", plan).Count(&n).Error; err != nil { |
| + return err |
| + } |
| + if n == 0 { |
| + return fmt.Errorf("abono: no offer for plan %q: %w", plan, ErrNotFound) |
| + } |
| + var have []priceRow |
| + if err := tx.Where("stripe_price_id = ?", priceID).Find(&have).Error; err != nil { |
| + return err |
| + } |
| + if len(have) == 1 && have[0].Plan != plan { |
| + return fmt.Errorf("abono: price %q belongs to plan %q, not %q", priceID, have[0].Plan, plan) |
| + } |
| + return upsertPrice(tx, plan, priceID, sold) |
| + }) |
| +} |
| + |
| +// Offers lists the plans on sale, each with its prices (sold ones first), |
| +// in the order they were first seeded. An archived offer is left out. |
| +func (s *Service) Offers(ctx context.Context) ([]Offer, error) { |
| + g := s.cfg.DB.WithContext(ctx) |
| + var rows []offerRow |
| + if err := g.Where("archived_at IS NULL").Order("created_at, plan").Find(&rows).Error; err != nil { |
| + return nil, err |
| + } |
| + var prices []priceRow |
| + if err := g.Order("sold DESC, stripe_price_id").Find(&prices).Error; err != nil { |
| + return nil, err |
| + } |
| + byPlan := map[string][]Price{} |
| + for _, p := range prices { |
| + byPlan[p.Plan] = append(byPlan[p.Plan], Price{ |
| + ID: p.StripePriceID, Sold: p.Sold, Interval: p.Interval, Amount: p.Amount, Currency: p.Currency, |
| + }) |
| + } |
| + offers := make([]Offer, 0, len(rows)) |
| + for _, r := range rows { |
| + name := r.Name |
| + if name == "" { |
| + name = r.Plan |
| + } |
| + offers = append(offers, Offer{Plan: r.Plan, Name: name, Prices: byPlan[r.Plan]}) |
| + } |
| + return offers, nil |
| +} |
diff --git a/offers_test.go b/offers_test.go| new file mode 100644 |
| index 0000000..e75e6d8 |
| --- /dev/null |
| +++ b/offers_test.go |
| @@ -0,0 +1,132 @@ |
| +package abono_test |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/abono" |
| +) |
| + |
| +func TestSeedPlansIsIdempotent(t *testing.T) { |
| + now := t0 |
| + s, db := newService(t, &now, abono.Config{}) |
| + ctx := context.Background() |
| + |
| + spec := []abono.PlanSpec{ |
| + {Plan: "annual", Sold: "price_A", Legacy: []string{"price_old"}}, |
| + {Plan: "monthly", Sold: "price_B"}, |
| + } |
| + if err := s.SeedPlans(ctx, spec); err != nil { |
| + t.Fatalf("SeedPlans: %v", err) |
| + } |
| + first := db.dump("SELECT plan, name FROM abono_offers ORDER BY plan") + |
| + db.dump("SELECT stripe_price_id, plan, sold FROM abono_prices ORDER BY stripe_price_id") |
| + if err := s.SeedPlans(ctx, spec); err != nil { |
| + t.Fatalf("second SeedPlans: %v", err) |
| + } |
| + second := db.dump("SELECT plan, name FROM abono_offers ORDER BY plan") + |
| + db.dump("SELECT stripe_price_id, plan, sold FROM abono_prices ORDER BY stripe_price_id") |
| + if first != second { |
| + t.Errorf("a second SeedPlans changed the rows:\nfirst:\n%s\nsecond:\n%s", first, second) |
| + } |
| + want := "annual|\nmonthly|\n" + "price_A|annual|1\nprice_B|monthly|1\nprice_old|annual|0\n" |
| + if first != want { |
| + t.Errorf("rows =\n%s\nwant\n%s", first, want) |
| + } |
| + |
| + // A price removed from the spec stays: existing subscriptions on it |
| + // must still resolve to their plan. |
| + if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_A"}, {Plan: "monthly", Sold: "price_B"}}); err != nil { |
| + t.Fatalf("SeedPlans without the legacy price: %v", err) |
| + } |
| + if got := db.dump("SELECT stripe_price_id FROM abono_prices WHERE stripe_price_id = 'price_old'"); got != "price_old\n" { |
| + t.Errorf("price_old was dropped from the table: %q", got) |
| + } |
| + |
| + // A new sold price takes over from the old one named in the spec. |
| + if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_C", Legacy: []string{"price_A"}}}); err != nil { |
| + t.Fatalf("SeedPlans with a new sold price: %v", err) |
| + } |
| + if got := db.dump("SELECT stripe_price_id, sold FROM abono_prices WHERE plan = 'annual' ORDER BY stripe_price_id"); got != "price_A|0\nprice_C|1\nprice_old|0\n" { |
| + t.Errorf("annual prices = %q", got) |
| + } |
| +} |
| + |
| +func TestSeedPlansRefusesBadSpecs(t *testing.T) { |
| + now := t0 |
| + s, db := newService(t, &now, abono.Config{}) |
| + for name, spec := range map[string][]abono.PlanSpec{ |
| + "no plan name": {{Sold: "price_A"}}, |
| + "no sold price": {{Plan: "annual"}}, |
| + "a blank legacy price": {{Plan: "annual", Sold: "price_A", Legacy: []string{""}}}, |
| + "a price in two plans": {{Plan: "annual", Sold: "price_A"}, {Plan: "monthly", Sold: "price_B", Legacy: []string{"price_A"}}}, |
| + "a plan twice": {{Plan: "annual", Sold: "price_A"}, {Plan: "annual", Sold: "price_B"}}, |
| + "a price twice in a plan": {{Plan: "annual", Sold: "price_A", Legacy: []string{"price_A"}}}, |
| + } { |
| + if err := s.SeedPlans(context.Background(), spec); err == nil { |
| + t.Errorf("%s: SeedPlans succeeded, want an error", name) |
| + } |
| + } |
| + if got := db.dump("SELECT plan FROM abono_offers"); got != "" { |
| + t.Errorf("a refused spec left offers behind: %q", got) |
| + } |
| +} |
| + |
| +func TestOffersAndAddPrice(t *testing.T) { |
| + now := t0 |
| + s, db := newService(t, &now, abono.Config{}) |
| + ctx := context.Background() |
| + |
| + if err := s.SeedPlans(ctx, []abono.PlanSpec{ |
| + {Plan: "annual", Sold: "price_A", Legacy: []string{"price_old"}}, |
| + {Plan: "monthly", Sold: "price_B"}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + // What a later refresh from Stripe would record. |
| + db.exec("UPDATE abono_prices SET interval = 'year', amount = 4900, currency = 'eur' WHERE stripe_price_id = 'price_A'") |
| + db.exec("UPDATE abono_offers SET name = 'Annual membership' WHERE plan = 'annual'") |
| + |
| + offers, err := s.Offers(ctx) |
| + if err != nil { |
| + t.Fatalf("Offers: %v", err) |
| + } |
| + if len(offers) != 2 || offers[0].Plan != "annual" || offers[1].Plan != "monthly" { |
| + t.Fatalf("offers = %+v", offers) |
| + } |
| + if offers[0].Name != "Annual membership" { |
| + t.Errorf("annual Name = %q", offers[0].Name) |
| + } |
| + if offers[1].Name != "monthly" { |
| + t.Errorf("monthly Name = %q, want it to default to the plan", offers[1].Name) |
| + } |
| + annual := offers[0].Prices |
| + if len(annual) != 2 || annual[0] != (abono.Price{ID: "price_A", Sold: true, Interval: "year", Amount: 4900, Currency: "eur"}) || |
| + annual[1].ID != "price_old" || annual[1].Sold { |
| + t.Errorf("annual prices = %+v, want the sold price first", annual) |
| + } |
| + |
| + if err := s.AddPrice(ctx, "monthly", "price_B2", true); err != nil { |
| + t.Fatalf("AddPrice: %v", err) |
| + } |
| + if err := s.AddPrice(ctx, "monthly", "price_B2", false); err != nil { |
| + t.Fatalf("AddPrice again: %v", err) |
| + } |
| + if err := s.AddPrice(ctx, "nope", "price_X", true); err == nil { |
| + t.Error("AddPrice to an unknown plan succeeded") |
| + } |
| + if err := s.AddPrice(ctx, "annual", "price_B", true); err == nil { |
| + t.Error("AddPrice moved a price that belongs to another plan") |
| + } |
| + offers, _ = s.Offers(ctx) |
| + if got := offers[1].Prices; len(got) != 2 || got[0].ID != "price_B" || got[1].ID != "price_B2" || got[1].Sold { |
| + t.Errorf("monthly prices = %+v", got) |
| + } |
| + |
| + // An archived offer is not on offer. |
| + db.exec("UPDATE abono_offers SET archived_at = '2026-10-01 00:00:00' WHERE plan = 'monthly'") |
| + offers, _ = s.Offers(ctx) |
| + if len(offers) != 1 || offers[0].Plan != "annual" { |
| + t.Errorf("offers after archiving = %+v", offers) |
| + } |
| +} |