rastrillo / abono Public

abono: SeedPlans keeps one sold price per plan

Summary

SeedPlans now sets sold=0 on every other price of a plan it seeds, listed in the spec or not, so a plan never has two sold prices. Nothing is deleted.
Agent notes
Controller ruling. A price added with AddPrice(sold) is demoted by the next seed of its plan.
claude agent, operated by paul@keymail.dev 9908e62d6fd3d9a7312d6f0031342f5003e3b221 parent 3cc52f1
2 files changed, +38 −4
  • offers.go +12 −4
  • offers_test.go +26 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./... -count=1
ok  	amadan.net/rastrillo/abono	0.130s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	0.007s
=== race ===
CGO_ENABLED=1 go test ./... -race -count=1
ok  	amadan.net/rastrillo/abono	2.275s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	1.044s
=== browser ===
browser: no browser test file yet, nothing to run
diff --git a/offers.go b/offers.go
index 39bb427..7069157 100644
--- a/offers.go
+++ b/offers.go
@@ -123,10 +123,11 @@ type priceRow struct {
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
+// its prices, the first (Sold) with sold=1 and every other price of the
+// plan, listed or not, with sold=0. It is idempotent and it never deletes:
+// a price dropped from the specs stays (no longer sold), because
+// subscriptions already on it must still resolve to their plan. A price
+// added with AddPrice(sold) is demoted by the next seed of its 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 {
@@ -147,6 +148,13 @@ func (s *Service) SeedPlans(ctx context.Context, specs []PlanSpec) error {
return err
}
}
+ // One sold price per plan, or Checkout cannot tell which to sell:
+ // whatever else the plan holds, named in the spec or not, is legacy.
+ if err := tx.Exec(
+ `UPDATE abono_prices SET sold = 0 WHERE plan = ? AND stripe_price_id <> ?`,
+ sp.Plan, sp.Sold).Error; err != nil {
+ return err
+ }
}
return nil
})
diff --git a/offers_test.go b/offers_test.go
index e75e6d8..9302631 100644
--- a/offers_test.go
+++ b/offers_test.go
@@ -130,3 +130,29 @@ func TestOffersAndAddPrice(t *testing.T) {
t.Errorf("offers after archiving = %+v", offers)
}
}
+
+func TestSeedPlansDemotesTheOldSoldPrice(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"}, {Plan: "monthly", Sold: "price_M"}}); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_B", Legacy: []string{"price_A"}}}); err != nil {
+ t.Fatal(err)
+ }
+ got := db.dump("SELECT stripe_price_id, plan, sold FROM abono_prices ORDER BY stripe_price_id")
+ if want := "price_A|annual|0\nprice_B|annual|1\nprice_M|monthly|1\n"; got != want {
+ t.Errorf("prices =\n%s\nwant\n%s", got, want)
+ }
+
+ // A price the spec no longer names is demoted too, and still not deleted.
+ if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_C"}}); err != nil {
+ t.Fatal(err)
+ }
+ got = db.dump("SELECT stripe_price_id, sold FROM abono_prices WHERE plan = 'annual' ORDER BY stripe_price_id")
+ if want := "price_A|0\nprice_B|0\nprice_C|1\n"; got != want {
+ t.Errorf("annual prices =\n%s\nwant\n%s", got, want)
+ }
+}