package abono import ( "context" "errors" "fmt" "regexp" "strings" "time" "gorm.io/gorm" "amadan.net/rastrillo/abono/internal/stripeapi" ) // PlanSpec is one plan an app sells, as an environment variable or a seed // says it: the price sold now, and older prices whose subscriptions are the // same plan (a WordPress site's, say). type PlanSpec struct { Plan string Sold string Legacy []string } // planName is a plan's address-safe name: lower-case words joined by // hyphens, so a plan can sit in a URL. var planName = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) // ParsePlans reads Farshad's format, "annual=price_A|price_old,monthly=price_B": // the first price after = is the one sold, the rest are legacy. Plans are // separated by commas, spaces or newlines. A price may belong to only one // plan, once. func ParsePlans(s string) ([]PlanSpec, error) { var specs []PlanSpec for _, f := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' || r == '\t' || r == '\r' }) { name, prices, ok := strings.Cut(f, "=") ids := strings.Split(prices, "|") if !ok || !planName.MatchString(name) { return nil, fmt.Errorf("abono: plans: %q is not plan=price_...", f) } for _, id := range ids { if id == "" { return nil, fmt.Errorf("abono: plans: %q has an empty price", f) } } specs = append(specs, PlanSpec{Plan: name, Sold: ids[0], Legacy: ids[1:]}) } if err := validatePlans(specs); err != nil { return nil, err } return specs, nil } // validatePlans refuses a plan with no name or no sold price, a blank // legacy price, a plan named twice, and a price listed twice (in one plan or // two): a price must resolve to exactly one plan. func validatePlans(specs []PlanSpec) error { plans := map[string]bool{} prices := map[string]string{} for _, sp := range specs { if sp.Plan == "" { return errors.New("abono: plans: a plan has no name") } if plans[sp.Plan] { return fmt.Errorf("abono: plans: %q is listed twice", sp.Plan) } plans[sp.Plan] = true if sp.Sold == "" { return fmt.Errorf("abono: plans: %q has no price", sp.Plan) } for _, id := range append([]string{sp.Sold}, sp.Legacy...) { if id == "" { return fmt.Errorf("abono: plans: %q has an empty price", sp.Plan) } if other, dup := prices[id]; dup { if other == sp.Plan { return fmt.Errorf("abono: plans: price %q is listed twice in %q", id, sp.Plan) } return fmt.Errorf("abono: plans: price %q is in both %q and %q", id, other, sp.Plan) } prices[id] = sp.Plan } } return nil } // Offer is a plan on sale, with the prices that belong to it. type Offer struct { Plan string // Name is what to call the plan to a reader; the plan itself when no // name was given. Name string Prices []Price } // 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 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 { Plan string Name string CreatedAt time.Time ArchivedAt *time.Time } func (offerRow) TableName() string { return "abono_offers" } type priceRow struct { StripePriceID string `gorm:"primaryKey"` 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 // 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 { if err := validatePlans(specs); err != nil { return err } now := s.now() return s.tx(ctx, func(tx *gorm.DB) error { for _, sp := range specs { if err := tx.Exec( `INSERT INTO abono_offers (plan, name, created_at) VALUES (?, '', ?) ON CONFLICT(plan) DO UPDATE SET archived_at = NULL`, sp.Plan, now).Error; err != nil { return err } for i, id := range append([]string{sp.Sold}, sp.Legacy...) { if err := upsertPrice(tx, sp.Plan, id, i == 0); err != nil { 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 }) } // upsertPrice records that priceID belongs to plan, sold or not. The // price's interval, amount and currency are not touched. func upsertPrice(tx *gorm.DB, plan, priceID string, sold bool) error { return tx.Exec( `INSERT INTO abono_prices (stripe_price_id, plan, sold) VALUES (?, ?, ?) ON CONFLICT(stripe_price_id) DO UPDATE SET plan = excluded.plan, sold = excluded.sold`, priceID, plan, sold).Error } // AddPrice adds priceID to an existing offer, or changes whether it is // sold. A price that belongs to another plan is refused: moving it would // change what its subscriptions mean. // // A plan sells one price per interval, or Checkout cannot tell which to // sell (ruling R38). So a price added as sold is read from Stripe first, // which needs Stripe (or the sandbox) on, and any other sold price of the // plan that bills the same way (interval and interval_count) is made // legacy in the same transaction: adding a new yearly price replaces the // old yearly one and leaves a monthly one on sale. The plan's other sold // prices are read first too if they never have been. A price added as // legacy needs no read. func (s *Service) AddPrice(ctx context.Context, plan, priceID string, sold bool) error { if plan == "" || priceID == "" { return errors.New("abono: AddPrice needs a plan and a price") } var read stripeapi.Price if sold { if !s.On() { return errOff } // The others first, so the comparison below knows how each bills. if err := s.learnIntervals(ctx, plan); err != nil { return err } var err error if read, err = s.stripe.GetPrice(ctx, priceID); err != nil { return fmt.Errorf("abono: reading price %q: %w", priceID, err) } } return s.tx(ctx, func(tx *gorm.DB) error { var n int64 if err := tx.Table("abono_offers").Where("plan = ?", plan).Count(&n).Error; err != nil { return err } if n == 0 { return fmt.Errorf("abono: no offer for plan %q: %w", plan, ErrNotFound) } var have []priceRow if err := tx.Where("stripe_price_id = ?", priceID).Find(&have).Error; err != nil { return err } if len(have) == 1 && have[0].Plan != plan { return fmt.Errorf("abono: price %q belongs to plan %q, not %q", priceID, have[0].Plan, plan) } if err := upsertPrice(tx, plan, priceID, sold); err != nil { return err } if !sold { return nil } count := max(read.Recurring.IntervalCount, 1) if err := tx.Exec(`UPDATE abono_prices SET interval = ?, interval_count = ?, amount = ?, currency = ?, refreshed_at = ? WHERE stripe_price_id = ?`, read.Recurring.Interval, read.Recurring.IntervalCount, read.UnitAmount, read.Currency, s.now(), priceID).Error; err != nil { return err } return tx.Exec(`UPDATE abono_prices SET sold = 0 WHERE plan = ? AND sold = 1 AND stripe_price_id <> ? AND interval = ? AND max(interval_count, 1) = ?`, plan, priceID, read.Recurring.Interval, count).Error }) } // Offers lists the plans on sale, each with its prices (sold ones first), // in the order they were first seeded. An archived offer is left out. // // With Stripe (or the sandbox) on, it first reads from Stripe every price // never read before (a seeded one, or one added as legacy), so a page never // shows a price without its interval, amount and currency (ruling R42). // That is one call per new price, once. It is best-effort: a price Stripe // cannot read is logged and listed as it is, and asked for again next time. func (s *Service) Offers(ctx context.Context) ([]Offer, error) { g := s.cfg.DB.WithContext(ctx) if s.On() { var unread []string if err := g.Raw(`SELECT stripe_price_id FROM abono_prices WHERE refreshed_at IS NULL ORDER BY stripe_price_id`). Scan(&unread).Error; err != nil { return nil, err } for _, id := range unread { if err := s.refreshPrice(ctx, id); err != nil { s.noteRefusedKey(err) s.cfg.Logger.Warn("abono: offers: a price could not be read", "price", id, "err", err) } } } var rows []offerRow if err := g.Where("archived_at IS NULL").Order("created_at, plan").Find(&rows).Error; err != nil { return nil, err } var prices []priceRow if err := g.Order("sold DESC, stripe_price_id").Find(&prices).Error; err != nil { return nil, err } byPlan := map[string][]Price{} for _, p := range prices { byPlan[p.Plan] = append(byPlan[p.Plan], p.price()) } offers := make([]Offer, 0, len(rows)) for _, r := range rows { name := r.Name if name == "" { name = r.Plan } offers = append(offers, Offer{Plan: r.Plan, Name: name, Prices: byPlan[r.Plan]}) } return offers, nil }