abono: keep a price's interval_count, and never sell one billed other than monthly or yearly
Summary
stripeapi reads recurring.interval_count (Price.Recurring and Subscription.Recurring). abono_prices gains interval_count, written by every price refresh and by Adopt. abono.Price gains IntervalCount and an Unsupported method: anything but every one month or every one year. Checkout never picks an unsupported price, the plan-card and plan-picker partials and the generated pricing cards never show one, and the owner's offers page shows it without a per-month or per-year sentence and with a new badge, abono.owner.price_unsupported. The fake reports a count set with SetIntervalCount.
Agent notes
Final review I7, ruling R39. migrations/0001_init.sql edited before v0.1.0 a third time: frozenAbonoChecksum re-recorded from 5701b242... to 906331b7...; development databases need a reset. New key abono.owner.price_unsupported is rendered as a key and added to pendingReview and newKeys for the third mini copy review. An interval_count of 0 (not recorded yet) reads as 1. Example pages regenerated. Regressions: TestGetPrice and TestSubscriptionWire read the count; TestAnUnsupportedPriceIsNeverSold; TestUnsupportedPricesAreNeverOffered (partials); example TestUnsupportedPriceIsShownNotSold (pricing page, owner page, abonoCards).
18 files changed,
+230
−48
adopt.go+4 −3checkout.go+8 −4cmd/abono/main_test.go+1 −1cmd/abono/pages/abono_pages.go.tmpl+3 −2cmd/abono/pages/owner-offers.html.tmpl+4 −2example/abono_pages.go+3 −2example/app_test.go+29 −0example/templates/abono/owner-offers.html+4 −2internal/fakestripe/fake.go+23 −2internal/stripeapi/client.go+23 −10internal/stripeapi/client_test.go+9 −4migrations/0001_init.sql+4 −1offers.go+32 −10offers_test.go+35 −0schema_test.go+1 −1templates/abono-plan-card.html+4 −2templates/abono-plan-picker.html+3 −2templates_test.go+40 −0
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... -count=1 ok amadan.net/rastrillo/abono 2.668s ok amadan.net/rastrillo/abono/abonotest 0.068s ok amadan.net/rastrillo/abono/cmd/abono 0.799s ok amadan.net/rastrillo/abono/internal/fakestripe 0.021s ok amadan.net/rastrillo/abono/internal/stripeapi 0.008s ok example 0.172s === race === CGO_ENABLED=1 go test ./... -race -count=1 ok amadan.net/rastrillo/abono 8.482s ok amadan.net/rastrillo/abono/abonotest 1.457s ok amadan.net/rastrillo/abono/cmd/abono 1.959s ok amadan.net/rastrillo/abono/internal/fakestripe 1.080s ok amadan.net/rastrillo/abono/internal/stripeapi 1.022s ok example 1.972s === browser === ok example 0.725s
Intent
diff --git a/adopt.go b/adopt.go| index 775e477..a9a4671 100644 |
| --- a/adopt.go |
| +++ b/adopt.go |
| @@ -38,7 +38,8 @@ func (s *Service) Adopt(ctx context.Context, subscriptionID, subject, plan strin |
| if err != nil { |
| return err |
| } |
| - price, interval := sub.PriceID() |
| + price, _ := sub.PriceID() |
| + interval, count := sub.Recurring() |
| if price == "" { |
| return fmt.Errorf("abono: subscription %q has no price", subscriptionID) |
| } |
| @@ -55,8 +56,8 @@ func (s *Service) Adopt(ctx context.Context, subscriptionID, subject, plan strin |
| case err != nil: |
| return err |
| case on == "": |
| - if err := tx.Exec(`INSERT INTO abono_prices (stripe_price_id, plan, sold, interval) VALUES (?, ?, 0, ?)`, |
| - price, plan, interval).Error; err != nil { |
| + if err := tx.Exec(`INSERT INTO abono_prices (stripe_price_id, plan, sold, interval, interval_count) VALUES (?, ?, 0, ?, ?)`, |
| + price, plan, interval, count).Error; err != nil { |
| return err |
| } |
| case on != plan: |
diff --git a/checkout.go b/checkout.go| index 63e5fa5..69ae22d 100644 |
| --- a/checkout.go |
| +++ b/checkout.go |
| @@ -138,15 +138,16 @@ func (s *Service) refreshPrice(ctx context.Context, id string) error { |
| return fmt.Errorf("abono: reading price %q: %w", id, err) |
| } |
| return s.cfg.DB.WithContext(ctx).Exec(`UPDATE abono_prices |
| - SET interval = ?, amount = ?, currency = ?, refreshed_at = ? WHERE stripe_price_id = ?`, |
| - p.Recurring.Interval, p.UnitAmount, p.Currency, s.now(), id).Error |
| + SET interval = ?, interval_count = ?, amount = ?, currency = ?, refreshed_at = ? WHERE stripe_price_id = ?`, |
| + p.Recurring.Interval, p.Recurring.IntervalCount, p.UnitAmount, p.Currency, s.now(), id).Error |
| } |
| // soldPrice is the price to sell for plan at interval: the sold price with |
| -// that interval, or with no interval the plan's only sold price. |
| +// that interval, or with no interval the plan's only sold price. A price |
| +// v0.1 cannot sell (Price.Unsupported) is never one of them. |
| func soldPrice(g *gorm.DB, plan, interval string) (string, error) { |
| var prices []priceRow |
| - if err := g.Raw(`SELECT p.stripe_price_id, p.interval FROM abono_prices p |
| + if err := g.Raw(`SELECT p.stripe_price_id, p.interval, p.interval_count FROM abono_prices p |
| JOIN abono_offers o ON o.plan = p.plan |
| WHERE p.plan = ? AND p.sold = 1 AND o.archived_at IS NULL |
| ORDER BY p.stripe_price_id`, plan).Scan(&prices).Error; err != nil { |
| @@ -154,6 +155,9 @@ func soldPrice(g *gorm.DB, plan, interval string) (string, error) { |
| } |
| var match []string |
| for _, p := range prices { |
| + if p.price().Unsupported() { |
| + continue |
| + } |
| if interval == "" || p.Interval == interval { |
| match = append(match, p.StripePriceID) |
| } |
diff --git a/cmd/abono/main_test.go b/cmd/abono/main_test.go| index ef9466d..37e58ca 100644 |
| --- a/cmd/abono/main_test.go |
| +++ b/cmd/abono/main_test.go |
| @@ -284,7 +284,7 @@ var partialKeys = []string{"abono.price.per_month", "abono.price.per_year"} |
| // newKeys are keys the pages need that the draft lacks (ruling R28), |
| // going through their own copy review. The pages must use each. |
| -var newKeys = []string{"abono.owner.free_no_member", "abono.pages.done_processing"} |
| +var newKeys = []string{"abono.owner.free_no_member", "abono.pages.done_processing", "abono.owner.price_unsupported"} |
| var keyLiteral = regexp.MustCompile(`"(abono\.[a-z][a-z_.]*[a-z])"`) |
diff --git a/cmd/abono/pages/abono_pages.go.tmpl b/cmd/abono/pages/abono_pages.go.tmpl| index bdfeded..beddd43 100644 |
| --- a/cmd/abono/pages/abono_pages.go.tmpl |
| +++ b/cmd/abono/pages/abono_pages.go.tmpl |
| @@ -123,14 +123,15 @@ type abonoCard struct { |
| // abonoCards is a card per offer and sold interval, monthly before |
| // yearly. An offer sold at one price, or whose prices' intervals are not |
| // known yet, gets one card with no interval; an offer with nothing sold |
| -// gets none. |
| +// gets none. A price billed other than every month or every year |
| +// (Unsupported) is not sold, so it makes no card. |
| func abonoCards(offers []abono.Offer) []abonoCard { |
| var cards []abonoCard |
| for _, o := range offers { |
| var intervals []string |
| sold, unknown := 0, false |
| for _, pr := range o.Prices { |
| - if !pr.Sold { |
| + if !pr.Sold || pr.Unsupported() { |
| continue |
| } |
| sold++ |
diff --git a/cmd/abono/pages/owner-offers.html.tmpl b/cmd/abono/pages/owner-offers.html.tmpl| index 1ef14b1..e88c160 100644 |
| --- a/cmd/abono/pages/owner-offers.html.tmpl |
| +++ b/cmd/abono/pages/owner-offers.html.tmpl |
| @@ -1,5 +1,7 @@ |
| {{/* abono/owner-offers: GET /admin/offers, each plan with its prices |
| - (the one on sale, and old ones whose members keep the plan), and the |
| + (the one on sale, and old ones whose members keep the plan; a price |
| + billed other than every month or every year is marked unsupported, |
| + since it is never sold), and the |
| paying subscriptions on a price no plan has, each with a form that |
| puts it, and everyone else on its price, onto a plan. |
| @@ -24,7 +26,7 @@ |
| {{range .Offers}}<section rst-box> |
| <h2>{{.Name}}</h2> |
| <ul> |
| -{{range .Prices}}<li><code>{{.ID}}</code>{{if .Currency}}{{$m := money $L .Amount .Currency}} {{if eq .Interval "month"}}{{Tf $L "abono.price.per_month" "amount" $m}}{{else if eq .Interval "year"}}{{Tf $L "abono.price.per_year" "amount" $m}}{{else}}{{$m}}{{end}}{{end}} {{if .Sold}}{{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_sold") "Tone" "positive"}}{{else}}{{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_legacy")}}{{end}}</li> |
| +{{range .Prices}}<li><code>{{.ID}}</code>{{if .Currency}}{{$m := money $L .Amount .Currency}} {{if .Unsupported}}{{$m}}{{else if eq .Interval "month"}}{{Tf $L "abono.price.per_month" "amount" $m}}{{else if eq .Interval "year"}}{{Tf $L "abono.price.per_year" "amount" $m}}{{else}}{{$m}}{{end}}{{end}} {{if .Sold}}{{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_sold") "Tone" "positive"}}{{else}}{{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_legacy")}}{{end}}{{if .Unsupported}} {{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_unsupported") "Tone" "warning"}}{{end}}</li> |
| {{end}}</ul> |
| </section> |
| {{else}}{{template "empty-state" dict "L" $L "Body" (T $L "abono.owner.offers_empty")}} |
diff --git a/example/abono_pages.go b/example/abono_pages.go| index c6ccfd7..4ffe650 100644 |
| --- a/example/abono_pages.go |
| +++ b/example/abono_pages.go |
| @@ -123,14 +123,15 @@ type abonoCard struct { |
| // abonoCards is a card per offer and sold interval, monthly before |
| // yearly. An offer sold at one price, or whose prices' intervals are not |
| // known yet, gets one card with no interval; an offer with nothing sold |
| -// gets none. |
| +// gets none. A price billed other than every month or every year |
| +// (Unsupported) is not sold, so it makes no card. |
| func abonoCards(offers []abono.Offer) []abonoCard { |
| var cards []abonoCard |
| for _, o := range offers { |
| var intervals []string |
| sold, unknown := 0, false |
| for _, pr := range o.Prices { |
| - if !pr.Sold { |
| + if !pr.Sold || pr.Unsupported() { |
| continue |
| } |
| sold++ |
diff --git a/example/app_test.go b/example/app_test.go| index 1432a67..0cb83aa 100644 |
| --- a/example/app_test.go |
| +++ b/example/app_test.go |
| @@ -653,3 +653,32 @@ func TestBillingStaysAfterSwitchingToAnUnknownPrice(t *testing.T) { |
| t.Errorf("billing for a customer not the reader's: %d to %q", st, loc) |
| } |
| } |
| + |
| +// TestUnsupportedPriceIsShownNotSold is final review I7 (ruling R39) on |
| +// the generated pages: a sold price billed every twelve months is listed |
| +// for the owner, marked unsupported, and is not on the pricing page. |
| +func TestUnsupportedPriceIsShownNotSold(t *testing.T) { |
| + var d *db.DB |
| + app := newApp(t, func(c *Config) { d = c.DB }) |
| + // The sandbox read price_monthly at start-up as every month; make it |
| + // every twelve, as Stripe would report such a price. |
| + if err := d.G.Exec(`UPDATE abono_prices SET interval_count = 12 WHERE stripe_price_id = 'price_monthly'`).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + reader := newClient(t, app) |
| + status, body := reader.get("/subscribe") |
| + want(t, "the plans", status, http.StatusOK, body, `action="/subscribe/annual"`) |
| + if strings.Contains(body, `action="/subscribe/monthly"`) { |
| + t.Error("the pricing page sells a price billed every twelve months") |
| + } |
| + boss := newClient(t, app) |
| + boss.signin(owner) |
| + status, body = boss.get("/admin/offers") |
| + want(t, "the offers page", status, http.StatusOK, body, "price_monthly", say("abono.owner.price_unsupported")) |
| + if strings.Contains(body, say("abono.price.per_month")) { |
| + t.Error("the offers page calls a price billed every twelve months monthly") |
| + } |
| + if cards := abonoCards([]abono.Offer{{Plan: "dozen", Prices: []abono.Price{{ID: "p", Sold: true, Interval: "month", IntervalCount: 12, Currency: "eur"}}}}); len(cards) != 0 { |
| + t.Errorf("abonoCards made %d cards for an unsupported price", len(cards)) |
| + } |
| +} |
diff --git a/example/templates/abono/owner-offers.html b/example/templates/abono/owner-offers.html| index 1ef14b1..e88c160 100644 |
| --- a/example/templates/abono/owner-offers.html |
| +++ b/example/templates/abono/owner-offers.html |
| @@ -1,5 +1,7 @@ |
| {{/* abono/owner-offers: GET /admin/offers, each plan with its prices |
| - (the one on sale, and old ones whose members keep the plan), and the |
| + (the one on sale, and old ones whose members keep the plan; a price |
| + billed other than every month or every year is marked unsupported, |
| + since it is never sold), and the |
| paying subscriptions on a price no plan has, each with a form that |
| puts it, and everyone else on its price, onto a plan. |
| @@ -24,7 +26,7 @@ |
| {{range .Offers}}<section rst-box> |
| <h2>{{.Name}}</h2> |
| <ul> |
| -{{range .Prices}}<li><code>{{.ID}}</code>{{if .Currency}}{{$m := money $L .Amount .Currency}} {{if eq .Interval "month"}}{{Tf $L "abono.price.per_month" "amount" $m}}{{else if eq .Interval "year"}}{{Tf $L "abono.price.per_year" "amount" $m}}{{else}}{{$m}}{{end}}{{end}} {{if .Sold}}{{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_sold") "Tone" "positive"}}{{else}}{{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_legacy")}}{{end}}</li> |
| +{{range .Prices}}<li><code>{{.ID}}</code>{{if .Currency}}{{$m := money $L .Amount .Currency}} {{if .Unsupported}}{{$m}}{{else if eq .Interval "month"}}{{Tf $L "abono.price.per_month" "amount" $m}}{{else if eq .Interval "year"}}{{Tf $L "abono.price.per_year" "amount" $m}}{{else}}{{$m}}{{end}}{{end}} {{if .Sold}}{{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_sold") "Tone" "positive"}}{{else}}{{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_legacy")}}{{end}}{{if .Unsupported}} {{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_unsupported") "Tone" "warning"}}{{end}}</li> |
| {{end}}</ul> |
| </section> |
| {{else}}{{template "empty-state" dict "L" $L "Body" (T $L "abono.owner.offers_empty")}} |
diff --git a/internal/fakestripe/fake.go b/internal/fakestripe/fake.go| index adb11c1..fbd0904 100644 |
| --- a/internal/fakestripe/fake.go |
| +++ b/internal/fakestripe/fake.go |
| @@ -114,6 +114,7 @@ type Fake struct { |
| type price struct { |
| id, plan, currency, interval string |
| amount int64 |
| + count int // interval_count; 0 reads as 1 |
| } |
| type customer struct{ id, email, name string } |
| @@ -177,6 +178,17 @@ func (f *Fake) AddPrice(id, plan string, amount int64, currency, interval string |
| f.prices[id] = &price{id: id, plan: plan, amount: amount, currency: currency, interval: interval} |
| } |
| +// SetIntervalCount makes price id bill every n of its intervals (a price |
| +// of "month" with n 12 bills once a year). AddPrice's prices bill every |
| +// one. |
| +func (f *Fake) SetIntervalCount(id string, n int) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + if p := f.prices[id]; p != nil { |
| + p.count = n |
| + } |
| +} |
| + |
| // AddPromotionCode makes code a live promotion code with id. |
| func (f *Fake) AddPromotionCode(code, id string) { |
| f.mu.Lock() |
| @@ -822,7 +834,7 @@ func (f *Fake) subObject(s *subscription, expand bool) map[string]any { |
| } |
| var interval any |
| if s.interval != "" { |
| - interval = map[string]any{"interval": s.interval, "interval_count": 1} |
| + interval = map[string]any{"interval": s.interval, "interval_count": intervalCount(f.prices[s.price])} |
| } |
| metadata := map[string]string{} |
| for k, v := range s.metadata { |
| @@ -870,7 +882,7 @@ func (f *Fake) invoice(s *subscription, status string) map[string]any { |
| func priceObject(p *price) map[string]any { |
| var recurring any |
| if p.interval != "" { |
| - recurring = map[string]any{"interval": p.interval, "interval_count": 1} |
| + recurring = map[string]any{"interval": p.interval, "interval_count": intervalCount(p)} |
| } |
| typ := "recurring" |
| if recurring == nil { |
| @@ -880,6 +892,15 @@ func priceObject(p *price) map[string]any { |
| "recurring": recurring, "type": typ, "active": true} |
| } |
| +// intervalCount is p's interval_count, 1 unless SetIntervalCount said |
| +// otherwise. |
| +func intervalCount(p *price) int { |
| + if p == nil || p.count < 1 { |
| + return 1 |
| + } |
| + return p.count |
| +} |
| + |
| // periodAfter is the end of the n-th period of interval counted from |
| // anchor. Months and years keep the anchor's day, clamped to the last day |
| // of a shorter month (31 January, 28 February, 31 March), which is how |
diff --git a/internal/stripeapi/client.go b/internal/stripeapi/client.go| index f92f5d9..a7e73ff 100644 |
| --- a/internal/stripeapi/client.go |
| +++ b/internal/stripeapi/client.go |
| @@ -54,10 +54,8 @@ type Subscription struct { |
| CurrentPeriodStart int64 `json:"current_period_start"` |
| CurrentPeriodEnd int64 `json:"current_period_end"` |
| Price struct { |
| - ID string `json:"id"` |
| - Recurring struct { |
| - Interval string `json:"interval"` |
| - } `json:"recurring"` |
| + ID string `json:"id"` |
| + Recurring Recurring `json:"recurring"` |
| } `json:"price"` |
| } `json:"data"` |
| } `json:"items"` |
| @@ -111,6 +109,16 @@ func (s Subscription) PriceID() (id, interval string) { |
| return p.ID, p.Recurring.Interval |
| } |
| +// Recurring is how often the first item's price bills: every count |
| +// intervals ("month", 12 is yearly in twelve monthly steps, not monthly). |
| +func (s Subscription) Recurring() (interval string, count int) { |
| + if len(s.Items.Data) == 0 { |
| + return "", 0 |
| + } |
| + r := s.Items.Data[0].Price.Recurring |
| + return r.Interval, r.IntervalCount |
| +} |
| + |
| // subscriptionWire is a subscription as the API sends it, whose |
| // customer is an id unless expanded. |
| type subscriptionWire struct { |
| @@ -172,12 +180,17 @@ func (c *Client) ListSubscriptions(ctx context.Context, each func(Subscription) |
| // Price is a recurring price: what a plan costs and how often. |
| type Price struct { |
| - ID string `json:"id"` |
| - UnitAmount int64 `json:"unit_amount"` |
| - Currency string `json:"currency"` |
| - Recurring struct { |
| - Interval string `json:"interval"` |
| - } `json:"recurring"` |
| + ID string `json:"id"` |
| + UnitAmount int64 `json:"unit_amount"` |
| + Currency string `json:"currency"` |
| + Recurring Recurring `json:"recurring"` |
| +} |
| + |
| +// Recurring is how often a price bills: every IntervalCount Intervals. |
| +// Both are empty for a one-off price. |
| +type Recurring struct { |
| + Interval string `json:"interval"` |
| + IntervalCount int `json:"interval_count"` |
| } |
| // GetPrice fetches one price. |
diff --git a/internal/stripeapi/client_test.go b/internal/stripeapi/client_test.go| index 56b027c..3c9fd27 100644 |
| --- a/internal/stripeapi/client_test.go |
| +++ b/internal/stripeapi/client_test.go |
| @@ -17,8 +17,8 @@ import ( |
| func TestSubscriptionWire(t *testing.T) { |
| t.Parallel() |
| for name, raw := range map[string]string{ |
| - "basil, expanded": `{"id":"sub_1","status":"active","customer":{"id":"cus_1","email":"a@example.com"},"items":{"data":[{"current_period_start":1700000000,"current_period_end":1800000000,"price":{"id":"price_A","recurring":{"interval":"year"}}}]}}`, |
| - "older, id only": `{"id":"sub_1","status":"active","customer":"cus_1","current_period_start":1700000000,"current_period_end":1800000000,"items":{"data":[{"price":{"id":"price_A","recurring":{"interval":"year"}}}]}}`, |
| + "basil, expanded": `{"id":"sub_1","status":"active","customer":{"id":"cus_1","email":"a@example.com"},"items":{"data":[{"current_period_start":1700000000,"current_period_end":1800000000,"price":{"id":"price_A","recurring":{"interval":"year","interval_count":2}}}]}}`, |
| + "older, id only": `{"id":"sub_1","status":"active","customer":"cus_1","current_period_start":1700000000,"current_period_end":1800000000,"items":{"data":[{"price":{"id":"price_A","recurring":{"interval":"year","interval_count":2}}}]}}`, |
| } { |
| var w subscriptionWire |
| if err := json.Unmarshal([]byte(raw), &w); err != nil { |
| @@ -29,6 +29,9 @@ func TestSubscriptionWire(t *testing.T) { |
| t.Fatal(err) |
| } |
| price, interval := s.PriceID() |
| + if _, count := s.Recurring(); count != 2 { |
| + t.Errorf("%s: interval_count %d, want 2", name, count) |
| + } |
| if s.Customer.ID != "cus_1" || price != "price_A" || interval != "year" || !s.PeriodEnd().Equal(time.Unix(1_800_000_000, 0)) || |
| !s.PeriodStart().Equal(time.Unix(1_700_000_000, 0)) { |
| t.Errorf("%s: %+v", name, s) |
| @@ -174,13 +177,15 @@ func TestGetPrice(t *testing.T) { |
| var path string |
| c := fake(t, func(w http.ResponseWriter, r *http.Request) { |
| path = r.URL.Path |
| - w.Write([]byte(`{"id":"price_A","unit_amount":4900,"currency":"eur","recurring":{"interval":"year"}}`)) |
| + w.Write([]byte(`{"id":"price_A","unit_amount":4900,"currency":"eur","recurring":{"interval":"month","interval_count":12}}`)) |
| }) |
| p, err := c.GetPrice(context.Background(), "price_A") |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if path != "/v1/prices/price_A" || p.ID != "price_A" || p.UnitAmount != 4900 || p.Currency != "eur" || p.Recurring.Interval != "year" { |
| + // Every twelve months is not monthly (final review I7): the count is kept. |
| + if path != "/v1/prices/price_A" || p.ID != "price_A" || p.UnitAmount != 4900 || p.Currency != "eur" || |
| + p.Recurring.Interval != "month" || p.Recurring.IntervalCount != 12 { |
| t.Errorf("%s %+v", path, p) |
| } |
| } |
diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql| index 06f4948..47df524 100644 |
| --- a/migrations/0001_init.sql |
| +++ b/migrations/0001_init.sql |
| @@ -15,12 +15,15 @@ CREATE TABLE abono_offers ( |
| -- The Stripe prices that belong to a plan. `sold` marks a price the |
| -- app offers to new buyers; a price no longer sold stays, so that |
| --- existing subscriptions on it still resolve to their plan. |
| +-- existing subscriptions on it still resolve to their plan. A price |
| +-- bills every `interval_count` `interval`s, as Stripe last said (0 until |
| +-- read); v0.1 sells only every one month or one year. |
| CREATE TABLE abono_prices ( |
| stripe_price_id TEXT NOT NULL PRIMARY KEY, |
| plan TEXT NOT NULL REFERENCES abono_offers(plan), |
| sold INTEGER NOT NULL DEFAULT 0, |
| interval TEXT NOT NULL DEFAULT '', |
| + interval_count INTEGER NOT NULL DEFAULT 0, |
| amount INTEGER NOT NULL DEFAULT 0, |
| currency TEXT NOT NULL DEFAULT '', |
| refreshed_at DATETIME |
diff --git a/offers.go b/offers.go| index 7069157..11f7cfc 100644 |
| --- a/offers.go |
| +++ b/offers.go |
| @@ -91,14 +91,32 @@ type Offer struct { |
| 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. |
| +// Price is one Stripe price of an offer. Interval, IntervalCount, Amount |
| +// and Currency are what Stripe last said, and empty until a refresh has |
| +// read them. The price bills every IntervalCount Intervals. |
| type Price struct { |
| - ID string |
| - Sold bool |
| - Interval string |
| - Amount int64 |
| - Currency string |
| + ID string |
| + Sold bool |
| + Interval string |
| + IntervalCount int |
| + Amount int64 |
| + Currency string |
| +} |
| + |
| +// Unsupported reports whether the price recurs in a way v0.1 does not |
| +// sell (ruling R39): anything but every one month or every one year, such |
| +// as every twelve months or every week. Checkout refuses it and the |
| +// partials never show it for sale; the owner's offers page marks it. A |
| +// price not read from Stripe yet, or a one-off one, has no interval and is |
| +// not unsupported. An IntervalCount of 0 (not recorded) reads as 1. |
| +func (p Price) Unsupported() bool { |
| + switch p.Interval { |
| + case "": |
| + return false |
| + case "month", "year": |
| + return p.IntervalCount > 1 |
| + } |
| + return true |
| } |
| type offerRow struct { |
| @@ -115,11 +133,17 @@ type priceRow struct { |
| Plan string |
| Sold bool |
| Interval string |
| + IntervalCount int |
| Amount int64 |
| Currency string |
| RefreshedAt *time.Time |
| } |
| +func (p priceRow) price() Price { |
| + return Price{ID: p.StripePriceID, Sold: p.Sold, Interval: p.Interval, IntervalCount: p.IntervalCount, |
| + Amount: p.Amount, Currency: p.Currency} |
| +} |
| + |
| func (priceRow) TableName() string { return "abono_prices" } |
| // SeedPlans makes the database match specs: each plan gets an offer and |
| @@ -209,9 +233,7 @@ func (s *Service) Offers(ctx context.Context) ([]Offer, error) { |
| } |
| 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, |
| - }) |
| + byPlan[p.Plan] = append(byPlan[p.Plan], p.price()) |
| } |
| offers := make([]Offer, 0, len(rows)) |
| for _, r := range rows { |
diff --git a/offers_test.go b/offers_test.go| index 9302631..b10c544 100644 |
| --- a/offers_test.go |
| +++ b/offers_test.go |
| @@ -2,6 +2,7 @@ package abono_test |
| import ( |
| "context" |
| + "errors" |
| "testing" |
| "amadan.net/rastrillo/abono" |
| @@ -156,3 +157,37 @@ func TestSeedPlansDemotesTheOldSoldPrice(t *testing.T) { |
| t.Errorf("annual prices =\n%s\nwant\n%s", got, want) |
| } |
| } |
| + |
| +// TestAnUnsupportedPriceIsNeverSold is final review I7 (ruling R39): a |
| +// price of €120 every twelve months has Stripe's interval "month". abono |
| +// keeps its interval_count, never sells it, and says so on Offers. |
| +func TestAnUnsupportedPriceIsNeverSold(t *testing.T) { |
| + now := t0 |
| + d := newStripeDouble(t) |
| + s, db := d.service(t, &now, abono.Config{}) |
| + ctx := context.Background() |
| + d.price("price_12m", "month", 12000, "eur") |
| + d.SetIntervalCount("price_12m", 12) |
| + if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "dozen", Sold: "price_12m"}}); err != nil { |
| + t.Fatalf("SeedPlans: %v", err) |
| + } |
| + for _, iv := range []string{"month", ""} { |
| + _, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "dozen", Interval: iv, SuccessPath: "/done"}) |
| + if !errors.Is(err, abono.ErrUnknownPlan) { |
| + t.Errorf("Checkout(dozen, %q): err = %v, want ErrUnknownPlan", iv, err) |
| + } |
| + } |
| + if len(d.CheckoutForms()) != 0 { |
| + t.Error("a Checkout session was made for an unsupported price") |
| + } |
| + if got := db.dump(`SELECT interval, interval_count FROM abono_prices WHERE stripe_price_id = 'price_12m'`); got != "month|12\n" { |
| + t.Errorf("price_12m = %q, want month|12", got) |
| + } |
| + offers, err := s.Offers(ctx) |
| + if err != nil || len(offers) != 1 || len(offers[0].Prices) != 1 { |
| + t.Fatalf("Offers = %+v, %v", offers, err) |
| + } |
| + if p := offers[0].Prices[0]; p.IntervalCount != 12 || !p.Unsupported() { |
| + t.Errorf("Offers price = %+v, want every 12 months and unsupported", p) |
| + } |
| +} |
diff --git a/schema_test.go b/schema_test.go| index 5bca8c8..395eb13 100644 |
| --- a/schema_test.go |
| +++ b/schema_test.go |
| @@ -140,7 +140,7 @@ func TestSchemaTimestampsDecode(t *testing.T) { |
| // migration file beside it, with a new ID and a new entry here. See |
| // rastrillo's own migrate/frozen_checksums_test.go, which this test is |
| // deliberately shaped after. |
| -const frozenAbonoChecksum = "5701b2420d58c9f91d9c59038e8a7f4f242f98441468f6ba1f660ff707d37112" |
| +const frozenAbonoChecksum = "906331b740701f831c91bb51cc414f1f43b66502b24599ac6c01da6eaef52858" |
| func TestSchemaFrozenChecksum(t *testing.T) { |
| for _, m := range abono.Schema.All() { |
diff --git a/templates/abono-plan-card.html b/templates/abono-plan-card.html| index 931f625..f2bd8f0 100644 |
| --- a/templates/abono-plan-card.html |
| +++ b/templates/abono-plan-card.html |
| @@ -3,7 +3,9 @@ |
| A plan with nothing sold at Interval says so ("abono.plan.interval_unavailable") |
| and offers no button, rather than quietly selling another interval. |
| - Prices that are no longer sold (legacy prices) are never shown. |
| + Prices that are no longer sold (legacy prices) are never shown, nor |
| + is a price billed other than every month or every year |
| + (Price.Unsupported). |
| Keys: |
| Offer abono.Offer, required |
| @@ -13,4 +15,4 @@ |
| Action string, required — where the buy form posts |
| Code bool, optional — a promotion code field in the buy form |
| L *ui.Localizer */}} |
| -{{define "abono-plan-card"}}{{$L := localizer $}}{{$iv := or (opt . "Interval") ""}}{{$found := false}}<div rst-box><h3>{{or .Offer.Name .Offer.Plan}}</h3>{{range .Offer.Prices}}{{if and .Sold (or (eq $iv "") (eq .Interval $iv))}}{{$found = true}}{{if .Currency}}<p>{{template "abono-price" dict "L" $L "Price" .}}</p>{{end}}{{end}}{{end}}{{if $found}}{{template "abono-subscribe-button" dict "L" $L "Action" .Action "Plan" .Offer.Plan "Interval" $iv "Code" (opt . "Code")}}{{else}}<p>{{T $L "abono.plan.interval_unavailable"}}</p>{{end}}</div>{{end}} |
| +{{define "abono-plan-card"}}{{$L := localizer $}}{{$iv := or (opt . "Interval") ""}}{{$found := false}}<div rst-box><h3>{{or .Offer.Name .Offer.Plan}}</h3>{{range .Offer.Prices}}{{if and .Sold (not .Unsupported) (or (eq $iv "") (eq .Interval $iv))}}{{$found = true}}{{if .Currency}}<p>{{template "abono-price" dict "L" $L "Price" .}}</p>{{end}}{{end}}{{end}}{{if $found}}{{template "abono-subscribe-button" dict "L" $L "Action" .Action "Plan" .Offer.Plan "Interval" $iv "Code" (opt . "Code")}}{{else}}<p>{{T $L "abono.plan.interval_unavailable"}}</p>{{end}}</div>{{end}} |
diff --git a/templates/abono-plan-picker.html b/templates/abono-plan-picker.html| index 2d56157..ef63430 100644 |
| --- a/templates/abono-plan-picker.html |
| +++ b/templates/abono-plan-picker.html |
| @@ -1,6 +1,7 @@ |
| {{/* abono-plan-picker — the plans on sale, as cards grouped by interval: |
| the monthly plans, then the yearly ones. A plan appears in a group |
| - only when it has a price sold at that interval. With only one |
| + only when it has a price sold at that interval (every month or every |
| + year: an unsupported price is never shown). With only one |
| interval on sale there is one group and no switch label; with nothing |
| on sale the picker renders nothing. |
| @@ -11,4 +12,4 @@ |
| Offers []abono.Offer, required — from Service.Offers |
| Action string, required — where each card's buy form posts |
| L *ui.Localizer */}} |
| -{{define "abono-plan-picker"}}{{$L := localizer $}}{{$action := .Action}}{{$month := false}}{{$year := false}}{{range .Offers}}{{range .Prices}}{{if .Sold}}{{if eq .Interval "month"}}{{$month = true}}{{else if eq .Interval "year"}}{{$year = true}}{{end}}{{end}}{{end}}{{end}}{{if or $month $year}}<div{{if and $month $year}} role="group" aria-label="{{T $L "abono.price.switch_label"}}"{{end}}>{{range $iv := list "month" "year"}}{{if or (and (eq $iv "month") $month) (and (eq $iv "year") $year)}}<section>{{if and $month $year}}<p><strong>{{if eq $iv "month"}}{{T $L "abono.price.monthly"}}{{else}}{{T $L "abono.price.yearly"}}{{end}}</strong></p>{{end}}{{range $.Offers}}{{$has := false}}{{range .Prices}}{{if and .Sold (eq .Interval $iv)}}{{$has = true}}{{end}}{{end}}{{if $has}}{{template "abono-plan-card" dict "L" $L "Offer" . "Interval" $iv "Action" $action}}{{end}}{{end}}</section>{{end}}{{end}}</div>{{end}}{{end}} |
| +{{define "abono-plan-picker"}}{{$L := localizer $}}{{$action := .Action}}{{$month := false}}{{$year := false}}{{range .Offers}}{{range .Prices}}{{if and .Sold (not .Unsupported)}}{{if eq .Interval "month"}}{{$month = true}}{{else if eq .Interval "year"}}{{$year = true}}{{end}}{{end}}{{end}}{{end}}{{if or $month $year}}<div{{if and $month $year}} role="group" aria-label="{{T $L "abono.price.switch_label"}}"{{end}}>{{range $iv := list "month" "year"}}{{if or (and (eq $iv "month") $month) (and (eq $iv "year") $year)}}<section>{{if and $month $year}}<p><strong>{{if eq $iv "month"}}{{T $L "abono.price.monthly"}}{{else}}{{T $L "abono.price.yearly"}}{{end}}</strong></p>{{end}}{{range $.Offers}}{{$has := false}}{{range .Prices}}{{if and .Sold (not .Unsupported) (eq .Interval $iv)}}{{$has = true}}{{end}}{{end}}{{if $has}}{{template "abono-plan-card" dict "L" $L "Offer" . "Interval" $iv "Action" $action}}{{end}}{{end}}</section>{{end}}{{end}}</div>{{end}}{{end}} |
diff --git a/templates_test.go b/templates_test.go| index fda5fd1..c0cec61 100644 |
| --- a/templates_test.go |
| +++ b/templates_test.go |
| @@ -100,6 +100,9 @@ var ( |
| yearly = abono.Price{ID: "price_y", Sold: true, Interval: "year", Amount: 5000, Currency: "EUR"} |
| monthly = abono.Price{ID: "price_m", Sold: true, Interval: "month", Amount: 500, Currency: "EUR"} |
| oldYr = abono.Price{ID: "price_old", Sold: false, Interval: "year", Amount: 4000, Currency: "EUR"} |
| + // dozen is sold, monthly by Stripe's interval, but every twelve months: |
| + // v0.1 cannot sell it and must never show it as €120 a month. |
| + dozen = abono.Price{ID: "price_12m", Sold: true, Interval: "month", IntervalCount: 12, Amount: 12000, Currency: "EUR"} |
| ) |
| func supporter() abono.Offer { |
| @@ -342,6 +345,42 @@ func TestPlanCard(t *testing.T) { |
| } |
| } |
| +// TestUnsupportedPricesAreNeverOffered is final review I7 (ruling R39): |
| +// a price billed other than every month or every year is not shown or sold |
| +// by the card or the picker, whatever its interval says. |
| +func TestUnsupportedPricesAreNeverOffered(t *testing.T) { |
| + l := testLocalizer(t, testCatalog) |
| + odd := abono.Offer{Plan: "dozen", Name: "Dozen", Prices: []abono.Price{dozen}} |
| + got := render(t, "abono-plan-card", map[string]any{"L": l, "Offer": odd, "Interval": "month", "Action": "/subscribe"}) |
| + if strings.Contains(got, "€120.00") || strings.Contains(got, "T-PERMONTH") || strings.Contains(got, "<form") { |
| + t.Errorf("a card sells a price billed every twelve months as monthly:\n%s", got) |
| + } |
| + if !strings.Contains(got, "T-NOINTERVAL") { |
| + t.Errorf("the card does not say the plan is not sold that way:\n%s", got) |
| + } |
| + got = render(t, "abono-plan-picker", map[string]any{"L": l, "Offers": []abono.Offer{odd}, "Action": "/subscribe"}) |
| + if strings.TrimSpace(got) != "" { |
| + t.Errorf("the picker offers an unsupported price:\n%s", got) |
| + } |
| + got = render(t, "abono-plan-picker", map[string]any{"L": l, "Offers": []abono.Offer{odd, yearlyOnly()}, "Action": "/subscribe"}) |
| + if strings.Contains(got, "Dozen") || strings.Contains(got, "T-MONTHLY") { |
| + t.Errorf("the picker shows the unsupported plan or a monthly group:\n%s", got) |
| + } |
| + for _, c := range []struct { |
| + p abono.Price |
| + want bool |
| + }{ |
| + {dozen, true}, {monthly, false}, {yearly, false}, |
| + {abono.Price{Interval: "week", IntervalCount: 1}, true}, |
| + {abono.Price{Interval: "year", IntervalCount: 2}, true}, |
| + {abono.Price{}, false}, // not read from Stripe yet, or a one-off |
| + } { |
| + if got := c.p.Unsupported(); got != c.want { |
| + t.Errorf("%+v.Unsupported() = %v, want %v", c.p, got, c.want) |
| + } |
| + } |
| +} |
| + |
| // A key documented as optional may be left out, in either shape of data |
| // a partial takes: a map without it, or a struct without the field. |
| func TestOptionalKeysMayBeOmitted(t *testing.T) { |
| @@ -671,6 +710,7 @@ var pendingReview = []string{ |
| "abono.owner.free_no_member", |
| // The final-review fix wave's keys, for the third mini copy review. |
| "abono.pages.done_processing", |
| + "abono.owner.price_unsupported", |
| } |
| // Every key a consumer uses is in en.toml (bar the pendingReview ones), and |