| 1 | package abono |
| 2 | |
| 3 | import ( |
| 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). |
| 19 | type 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. |
| 27 | var 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. |
| 33 | func 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. |
| 57 | func 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. |
| 88 | type 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. |
| 99 | type 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. |
| 114 | func (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 | |
| 124 | type offerRow struct { |
| 125 | Plan string |
| 126 | Name string |
| 127 | CreatedAt time.Time |
| 128 | ArchivedAt *time.Time |
| 129 | } |
| 130 | |
| 131 | func (offerRow) TableName() string { return "abono_offers" } |
| 132 | |
| 133 | type 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 | |
| 144 | func (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 | |
| 149 | func (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. |
| 160 | func (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. |
| 192 | func 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. |
| 211 | func (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. |
| 270 | func (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 | |