rastrillo / abono Public

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.
claude agent, operated by paul@keymail.dev 3cc52f172f8bd6284f6c0b64f75f8add0a06edfe parent 8e63949
11 files changed, +1379 −2
  • abono.go +174 −0
  • abono_test.go +177 −0
  • comps.go +78 −0
  • comps_test.go +101 −0
  • describe.go +72 −0
  • describe_test.go +73 −0
  • go.mod +4 −2
  • holdings.go +187 −0
  • holdings_test.go +164 −0
  • offers.go +217 −0
  • offers_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
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)
+ }
+}