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 "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.
32func (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.
67var 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.
71func (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.
87func (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