| 1 | package abono |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "slices" |
| 8 | |
| 9 | "gorm.io/gorm" |
| 10 | |
| 11 | "amadan.net/rastrillo/abono/internal/stripeapi" |
| 12 | ) |
| 13 | |
| 14 | // Adopt takes an existing Stripe subscription as subject's holding of plan: |
| 15 | // a WordPress site's member being imported, or an unrecognised subscription |
| 16 | // an owner places on a plan. The subscription's price becomes a legacy |
| 17 | // price of plan (sold to nobody new) when no plan has it yet; a price that |
| 18 | // belongs to another plan is refused, since moving it would change what |
| 19 | // every subscription on it means. |
| 20 | // |
| 21 | // Every other subscription abono has recorded on that price and not yet on |
| 22 | // plan is synced again in the same transaction, so everyone paying that |
| 23 | // price holds the plan, and the app has been told, by the time Adopt |
| 24 | // returns (ruling R40), as the owner's page says. |
| 25 | // |
| 26 | // With subject "", the holder is found as a sync finds it: the customer's |
| 27 | // recorded subject, then Holder for the customer's address. With a |
| 28 | // subject, that subject is pinned: Checkout's reference and the |
| 29 | // subscription's abono_subject metadata, which may still name whoever |
| 30 | // bought it, never move it back (ruling R36). The row is recorded whatever |
| 31 | // the subscription's status, and OnChange runs in the same transaction. |
| 32 | func (s *Service) Adopt(ctx context.Context, subscriptionID, subject, plan string) error { |
| 33 | if !s.On() { |
| 34 | return errOff |
| 35 | } |
| 36 | if subscriptionID == "" || plan == "" { |
| 37 | return errors.New("abono: Adopt needs a subscription and a plan") |
| 38 | } |
| 39 | // The price decides which other subscriptions are locked with this |
| 40 | // one. It is read again inside the locks; should it have changed in |
| 41 | // between (a plan switch at that moment), the set is worked out again. |
| 42 | price, err := s.recordedPrice(ctx, subscriptionID) |
| 43 | if err != nil { |
| 44 | return err |
| 45 | } |
| 46 | for range 3 { |
| 47 | if price == "" { |
| 48 | sub, err := s.stripe.GetSubscription(ctx, subscriptionID) |
| 49 | if err != nil { |
| 50 | return err |
| 51 | } |
| 52 | if price, _ = sub.PriceID(); price == "" { |
| 53 | return fmt.Errorf("abono: subscription %q has no price", subscriptionID) |
| 54 | } |
| 55 | } |
| 56 | now, err := s.adopt(ctx, subscriptionID, subject, plan, price) |
| 57 | if !errors.Is(err, errPriceMoved) { |
| 58 | return err |
| 59 | } |
| 60 | price = now |
| 61 | } |
| 62 | return fmt.Errorf("abono: subscription %q kept changing its price while being adopted", subscriptionID) |
| 63 | } |
| 64 | |
| 65 | // errPriceMoved is adopt finding, inside the locks, that the subscription |
| 66 | // is no longer on the price it locked for. |
| 67 | var errPriceMoved = errors.New("abono: the subscription's price changed") |
| 68 | |
| 69 | // recordedPrice is the price abono last recorded for subscription id, or |
| 70 | // "" when there is no row. |
| 71 | func (s *Service) recordedPrice(ctx context.Context, id string) (string, error) { |
| 72 | var prices []string |
| 73 | err := s.cfg.DB.WithContext(ctx).Raw(`SELECT stripe_price_id FROM abono_subscriptions WHERE stripe_subscription_id = ?`, id). |
| 74 | Scan(&prices).Error |
| 75 | if err != nil || len(prices) == 0 { |
| 76 | return "", err |
| 77 | } |
| 78 | return prices[0], nil |
| 79 | } |
| 80 | |
| 81 | // adopt is Adopt for a subscription believed to be on price. It locks the |
| 82 | // subscription and every other recorded one on price not yet on plan, in |
| 83 | // id order (so two adoptions cannot wait on each other), fetches each from |
| 84 | // Stripe inside its lock (ruling R35), and writes them all in one |
| 85 | // transaction. When the subscription turns out to be on another price it |
| 86 | // returns errPriceMoved and that price. |
| 87 | func (s *Service) adopt(ctx context.Context, subscriptionID, subject, plan, price string) (string, error) { |
| 88 | var others []string |
| 89 | if err := s.cfg.DB.WithContext(ctx).Raw(`SELECT stripe_subscription_id FROM abono_subscriptions |
| 90 | WHERE stripe_price_id = ? AND stripe_subscription_id <> ? AND plan <> ? |
| 91 | ORDER BY stripe_subscription_id`, price, subscriptionID, plan).Scan(&others).Error; err != nil { |
| 92 | return "", err |
| 93 | } |
| 94 | ids := append([]string{subscriptionID}, others...) |
| 95 | slices.Sort(ids) |
| 96 | for _, id := range ids { |
| 97 | unlock, err := s.subs.lock(ctx, id) |
| 98 | if err != nil { |
| 99 | return "", err |
| 100 | } |
| 101 | defer unlock() |
| 102 | } |
| 103 | |
| 104 | sub, err := s.stripe.GetSubscription(ctx, subscriptionID) |
| 105 | if err != nil { |
| 106 | return "", err |
| 107 | } |
| 108 | now, _ := sub.PriceID() |
| 109 | switch { |
| 110 | case now == "": |
| 111 | return "", fmt.Errorf("abono: subscription %q has no price", subscriptionID) |
| 112 | case now != price: |
| 113 | return now, errPriceMoved |
| 114 | } |
| 115 | interval, count := sub.Recurring() |
| 116 | var rest []stripeapi.Subscription |
| 117 | for _, id := range others { |
| 118 | o, err := s.stripe.GetSubscription(ctx, id) |
| 119 | switch { |
| 120 | case stripeapi.NotFound(err): |
| 121 | continue // gone from Stripe; nothing to bring onto the plan |
| 122 | case err != nil: |
| 123 | return "", err |
| 124 | } |
| 125 | rest = append(rest, o) |
| 126 | } |
| 127 | |
| 128 | return "", s.tx(ctx, func(tx *gorm.DB) error { |
| 129 | var n int64 |
| 130 | if err := tx.Table("abono_offers").Where("plan = ?", plan).Count(&n).Error; err != nil { |
| 131 | return err |
| 132 | } |
| 133 | if n == 0 { |
| 134 | return fmt.Errorf("abono: no offer for plan %q: %w", plan, ErrNotFound) |
| 135 | } |
| 136 | on, err := planOf(tx, price) |
| 137 | switch { |
| 138 | case err != nil: |
| 139 | return err |
| 140 | case on == "": |
| 141 | if err := tx.Exec(`INSERT INTO abono_prices (stripe_price_id, plan, sold, interval, interval_count) VALUES (?, ?, 0, ?, ?)`, |
| 142 | price, plan, interval, count).Error; err != nil { |
| 143 | return err |
| 144 | } |
| 145 | case on != plan: |
| 146 | return fmt.Errorf("abono: price %q belongs to plan %q, not %q", price, on, plan) |
| 147 | } |
| 148 | if _, err := s.syncTx(ctx, tx, sub, Hint{Subject: subject}, true); err != nil { |
| 149 | return err |
| 150 | } |
| 151 | // Everyone else on the price, as their next event would sync them. |
| 152 | for _, o := range rest { |
| 153 | if _, err := s.syncTx(ctx, tx, o, subscriptionHint(o), false); err != nil { |
| 154 | return fmt.Errorf("abono: adopt: bringing subscription %s onto %q: %w", o.ID, plan, err) |
| 155 | } |
| 156 | } |
| 157 | return nil |
| 158 | }) |
| 159 | } |
| 160 | |