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.
2 files changed,
+38
−4
offers.go+12 −4offers_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
Intent
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) |
| + } |
| +} |