rastrillo / abono Public

Clone
git clone https://amadan.net/rastrillo/abono

Plain git — no account needed to clone.

Download

Download this file

1package abono
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "regexp"
8 "strings"
9 "time"
10
11 "gorm.io/gorm"
12
13 "amadan.net/rastrillo/abono/internal/stripeapi"
14)
15
16// PlanSpec is one plan an app sells, as an environment variable or a seed
17// says it: the price sold now, and older prices whose subscriptions are the
18// same plan (a WordPress site's, say).
19type PlanSpec struct {
20 Plan string
21 Sold string
22 Legacy []string
23}
24
25// planName is a plan's address-safe name: lower-case words joined by
26// hyphens, so a plan can sit in a URL.
27var planName = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
28
29// ParsePlans reads Farshad's format, "annual=price_A|price_old,monthly=price_B":
30// the first price after = is the one sold, the rest are legacy. Plans are
31// separated by commas, spaces or newlines. A price may belong to only one
32// plan, once.
33func ParsePlans(s string) ([]PlanSpec, error) {
34 var specs []PlanSpec
35 for _, f := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' || r == '\t' || r == '\r' }) {
36 name, prices, ok := strings.Cut(f, "=")
37 ids := strings.Split(prices, "|")
38 if !ok || !planName.MatchString(name) {
39 return nil, fmt.Errorf("abono: plans: %q is not plan=price_...", f)
40 }
41 for _, id := range ids {
42 if id == "" {
43 return nil, fmt.Errorf("abono: plans: %q has an empty price", f)
44 }
45 }
46 specs = append(specs, PlanSpec{Plan: name, Sold: ids[0], Legacy: ids[1:]})
47 }
48 if err := validatePlans(specs); err != nil {
49 return nil, err
50 }
51 return specs, nil
52}
53
54// validatePlans refuses a plan with no name or no sold price, a blank
55// legacy price, a plan named twice, and a price listed twice (in one plan or
56// two): a price must resolve to exactly one plan.
57func validatePlans(specs []PlanSpec) error {
58 plans := map[string]bool{}
59 prices := map[string]string{}
60 for _, sp := range specs {
61 if sp.Plan == "" {
62 return errors.New("abono: plans: a plan has no name")
63 }
64 if plans[sp.Plan] {
65 return fmt.Errorf("abono: plans: %q is listed twice", sp.Plan)
66 }
67 plans[sp.Plan] = true
68 if sp.Sold == "" {
69 return fmt.Errorf("abono: plans: %q has no price", sp.Plan)
70 }
71 for _, id := range append([]string{sp.Sold}, sp.Legacy...) {
72 if id == "" {
73 return fmt.Errorf("abono: plans: %q has an empty price", sp.Plan)
74 }
75 if other, dup := prices[id]; dup {
76 if other == sp.Plan {
77 return fmt.Errorf("abono: plans: price %q is listed twice in %q", id, sp.Plan)
78 }
79 return fmt.Errorf("abono: plans: price %q is in both %q and %q", id, other, sp.Plan)
80 }
81 prices[id] = sp.Plan
82 }
83 }
84 return nil
85}
86
87// Offer is a plan on sale, with the prices that belong to it.
88type Offer struct {
89 Plan string
90 // Name is what to call the plan to a reader; the plan itself when no
91 // name was given.
92 Name string
93 Prices []Price
94}
95
96// Price is one Stripe price of an offer. Interval, IntervalCount, Amount
97// and Currency are what Stripe last said, and empty until a refresh has
98// read them. The price bills every IntervalCount Intervals.
99type Price struct {
100 ID string
101 Sold bool
102 Interval string
103 IntervalCount int
104 Amount int64
105 Currency string
106}
107
108// Unsupported reports whether the price recurs in a way v0.1 does not
109// sell (ruling R39): anything but every one month or every one year, such
110// as every twelve months or every week. Checkout refuses it and the
111// partials never show it for sale; the owner's offers page marks it. A
112// price not read from Stripe yet, or a one-off one, has no interval and is
113// not unsupported. An IntervalCount of 0 (not recorded) reads as 1.
114func (p Price) Unsupported() bool {
115 switch p.Interval {
116 case "":
117 return false
118 case "month", "year":
119 return p.IntervalCount > 1
120 }
121 return true
122}
123
124type offerRow struct {
125 Plan string
126 Name string
127 CreatedAt time.Time
128 ArchivedAt *time.Time
129}
130
131func (offerRow) TableName() string { return "abono_offers" }
132
133type priceRow struct {
134 StripePriceID string `gorm:"primaryKey"`
135 Plan string
136 Sold bool
137 Interval string
138 IntervalCount int
139 Amount int64
140 Currency string
141 RefreshedAt *time.Time
142}
143
144func (p priceRow) price() Price {
145 return Price{ID: p.StripePriceID, Sold: p.Sold, Interval: p.Interval, IntervalCount: p.IntervalCount,
146 Amount: p.Amount, Currency: p.Currency}
147}
148
149func (priceRow) TableName() string { return "abono_prices" }
150
151// SeedPlans makes the database match specs: each plan gets an offer and
152// its prices, the first (Sold) with sold=1 and every other price of the
153// plan, listed or not, with sold=0. It is idempotent and it never deletes:
154// a price dropped from the specs stays (no longer sold), because
155// subscriptions already on it must still resolve to their plan. A price
156// added with AddPrice(sold) is demoted by the next seed of its plan.
157// Seeding a plan again unarchives it, since the app just said it is
158// for sale. A name an operator gave an offer, and what Stripe said of a
159// price, are left alone.
160func (s *Service) SeedPlans(ctx context.Context, specs []PlanSpec) error {
161 if err := validatePlans(specs); err != nil {
162 return err
163 }
164 now := s.now()
165 return s.tx(ctx, func(tx *gorm.DB) error {
166 for _, sp := range specs {
167 if err := tx.Exec(
168 `INSERT INTO abono_offers (plan, name, created_at) VALUES (?, '', ?)
169 ON CONFLICT(plan) DO UPDATE SET archived_at = NULL`,
170 sp.Plan, now).Error; err != nil {
171 return err
172 }
173 for i, id := range append([]string{sp.Sold}, sp.Legacy...) {
174 if err := upsertPrice(tx, sp.Plan, id, i == 0); err != nil {
175 return err
176 }
177 }
178 // One sold price per plan, or Checkout cannot tell which to sell:
179 // whatever else the plan holds, named in the spec or not, is legacy.
180 if err := tx.Exec(
181 `UPDATE abono_prices SET sold = 0 WHERE plan = ? AND stripe_price_id <> ?`,
182 sp.Plan, sp.Sold).Error; err != nil {
183 return err
184 }
185 }
186 return nil
187 })
188}
189
190// upsertPrice records that priceID belongs to plan, sold or not. The
191// price's interval, amount and currency are not touched.
192func upsertPrice(tx *gorm.DB, plan, priceID string, sold bool) error {
193 return tx.Exec(
194 `INSERT INTO abono_prices (stripe_price_id, plan, sold) VALUES (?, ?, ?)
195 ON CONFLICT(stripe_price_id) DO UPDATE SET plan = excluded.plan, sold = excluded.sold`,
196 priceID, plan, sold).Error
197}
198
199// AddPrice adds priceID to an existing offer, or changes whether it is
200// sold. A price that belongs to another plan is refused: moving it would
201// change what its subscriptions mean.
202//
203// A plan sells one price per interval, or Checkout cannot tell which to
204// sell (ruling R38). So a price added as sold is read from Stripe first,
205// which needs Stripe (or the sandbox) on, and any other sold price of the
206// plan that bills the same way (interval and interval_count) is made
207// legacy in the same transaction: adding a new yearly price replaces the
208// old yearly one and leaves a monthly one on sale. The plan's other sold
209// prices are read first too if they never have been. A price added as
210// legacy needs no read.
211func (s *Service) AddPrice(ctx context.Context, plan, priceID string, sold bool) error {
212 if plan == "" || priceID == "" {
213 return errors.New("abono: AddPrice needs a plan and a price")
214 }
215 var read stripeapi.Price
216 if sold {
217 if !s.On() {
218 return errOff
219 }
220 // The others first, so the comparison below knows how each bills.
221 if err := s.learnIntervals(ctx, plan); err != nil {
222 return err
223 }
224 var err error
225 if read, err = s.stripe.GetPrice(ctx, priceID); err != nil {
226 return fmt.Errorf("abono: reading price %q: %w", priceID, err)
227 }
228 }
229 return s.tx(ctx, func(tx *gorm.DB) error {
230 var n int64
231 if err := tx.Table("abono_offers").Where("plan = ?", plan).Count(&n).Error; err != nil {
232 return err
233 }
234 if n == 0 {
235 return fmt.Errorf("abono: no offer for plan %q: %w", plan, ErrNotFound)
236 }
237 var have []priceRow
238 if err := tx.Where("stripe_price_id = ?", priceID).Find(&have).Error; err != nil {
239 return err
240 }
241 if len(have) == 1 && have[0].Plan != plan {
242 return fmt.Errorf("abono: price %q belongs to plan %q, not %q", priceID, have[0].Plan, plan)
243 }
244 if err := upsertPrice(tx, plan, priceID, sold); err != nil {
245 return err
246 }
247 if !sold {
248 return nil
249 }
250 count := max(read.Recurring.IntervalCount, 1)
251 if err := tx.Exec(`UPDATE abono_prices SET interval = ?, interval_count = ?, amount = ?, currency = ?, refreshed_at = ?
252 WHERE stripe_price_id = ?`,
253 read.Recurring.Interval, read.Recurring.IntervalCount, read.UnitAmount, read.Currency, s.now(), priceID).Error; err != nil {
254 return err
255 }
256 return tx.Exec(`UPDATE abono_prices SET sold = 0
257 WHERE plan = ? AND sold = 1 AND stripe_price_id <> ? AND interval = ? AND max(interval_count, 1) = ?`,
258 plan, priceID, read.Recurring.Interval, count).Error
259 })
260}
261
262// Offers lists the plans on sale, each with its prices (sold ones first),
263// in the order they were first seeded. An archived offer is left out.
264//
265// With Stripe (or the sandbox) on, it first reads from Stripe every price
266// never read before (a seeded one, or one added as legacy), so a page never
267// shows a price without its interval, amount and currency (ruling R42).
268// That is one call per new price, once. It is best-effort: a price Stripe
269// cannot read is logged and listed as it is, and asked for again next time.
270func (s *Service) Offers(ctx context.Context) ([]Offer, error) {
271 g := s.cfg.DB.WithContext(ctx)
272 if s.On() {
273 var unread []string
274 if err := g.Raw(`SELECT stripe_price_id FROM abono_prices WHERE refreshed_at IS NULL ORDER BY stripe_price_id`).
275 Scan(&unread).Error; err != nil {
276 return nil, err
277 }
278 for _, id := range unread {
279 if err := s.refreshPrice(ctx, id); err != nil {
280 s.noteRefusedKey(err)
281 s.cfg.Logger.Warn("abono: offers: a price could not be read", "price", id, "err", err)
282 }
283 }
284 }
285 var rows []offerRow
286 if err := g.Where("archived_at IS NULL").Order("created_at, plan").Find(&rows).Error; err != nil {
287 return nil, err
288 }
289 var prices []priceRow
290 if err := g.Order("sold DESC, stripe_price_id").Find(&prices).Error; err != nil {
291 return nil, err
292 }
293 byPlan := map[string][]Price{}
294 for _, p := range prices {
295 byPlan[p.Plan] = append(byPlan[p.Plan], p.price())
296 }
297 offers := make([]Offer, 0, len(rows))
298 for _, r := range rows {
299 name := r.Name
300 if name == "" {
301 name = r.Plan
302 }
303 offers = append(offers, Offer{Plan: r.Plan, Name: name, Prices: byPlan[r.Plan]})
304 }
305 return offers, nil
306}
307