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 "strings"
7 "time"
8
9 "gorm.io/gorm"
10
11 "amadan.net/rastrillo/abono/internal/stripeapi"
12)
13
14// Hint is what a caller knows of whose a subscription is, beyond Stripe's
15// customer: Checkout's client_reference_id (or the subscription's
16// abono_subject metadata) and the address typed there.
17//
18// A hint does not decide whether a row is made: any sync of a subscription
19// that grants records it (rulings R16 and R16a), so a reconcile recovers a
20// webhook Stripe gave up on.
21type Hint struct {
22 Subject, Email string
23}
24
25// subRecord is a whole abono_subscriptions row.
26type subRecord struct {
27 StripeSubscriptionID string
28 StripeCustomerID string
29 Subject string
30 SubjectPinned bool
31 Plan string
32 StripePriceID string
33 Status string
34 CurrentPeriodStart *time.Time
35 CurrentPeriodEnd *time.Time
36 CancelAtPeriodEnd bool
37 UnpaidSince *time.Time
38 Email string
39 CreatedAt time.Time
40 UpdatedAt time.Time
41}
42
43func (subRecord) TableName() string { return "abono_subscriptions" }
44
45// sync makes abono's row for sub match it, in one transaction. The webhook
46// and the reconcile call it; Finish and Adopt use syncTx inside their own.
47func (s *Service) sync(ctx context.Context, sub stripeapi.Subscription, hint Hint) error {
48 return s.tx(ctx, func(tx *gorm.DB) error {
49 _, err := s.syncTx(ctx, tx, sub, hint, false)
50 return err
51 })
52}
53
54// syncTx is sync inside tx, and returns the row's subject afterwards ("" for
55// none, or for a payment not yet tied to anyone).
56//
57// The row is read again by subscription id first, inside the transaction.
58// SQLite has one writer, so two syncs of the same new subscription (Checkout's
59// return and its webhook, say) run one after the other: the second finds the
60// first's row and updates it, and Holder is asked at most once. Holder is
61// only ever called from here, inside tx.
62//
63// With no row yet, one is made when the subscription grants, or when adopt
64// is set (an owner said so). adopt with a hint subject is an explicit
65// decision of whose the subscription is: it pins the row's subject, and a
66// pinned subject is never changed by a hint after (ruling R36), only by
67// another adoption. Claim pins too. An ended subscription first seen now is never
68// recorded (the spec's "no resurrection" rule). The plan is the one whose
69// prices include the subscription's first price, or "" when none does: such
70// a subscription is recorded as unrecognised, never grants, and is listed
71// by Unrecognised for an owner to adopt; Holder is not asked about it.
72//
73// OnChange runs for the subject before and after whenever the subject,
74// plan, status, period end or cancel flag changed, but only for a side that
75// has a plan: an unrecognised row, like an unclaimed one, tells nobody.
76func (s *Service) syncTx(ctx context.Context, tx *gorm.DB, sub stripeapi.Subscription, hint Hint, adopt bool) (string, error) {
77 if sub.ID == "" {
78 return "", errors.New("abono: sync: a subscription with no id")
79 }
80 hint.Subject = strings.TrimSpace(hint.Subject)
81 hint.Email = normalEmail(hint.Email)
82
83 var have []subRecord
84 if err := tx.Where("stripe_subscription_id = ?", sub.ID).Limit(1).Find(&have).Error; err != nil {
85 return "", err
86 }
87 price, _ := sub.PriceID()
88 plan, err := planOf(tx, price)
89 if err != nil {
90 return "", err
91 }
92 now := s.now()
93 row := subRecord{
94 StripeSubscriptionID: sub.ID,
95 StripeCustomerID: sub.Customer.ID,
96 Plan: plan,
97 StripePriceID: price,
98 Status: sub.Status,
99 CancelAtPeriodEnd: sub.CancelAtPeriodEnd,
100 Email: firstAddress(sub, hint),
101 CreatedAt: now,
102 UpdatedAt: now,
103 }
104 if start := sub.PeriodStart(); !start.IsZero() {
105 row.CurrentPeriodStart = &start
106 }
107 if end := sub.PeriodEnd(); !end.IsZero() {
108 row.CurrentPeriodEnd = &end
109 }
110 var oldUnpaid *time.Time
111 if len(have) > 0 {
112 oldUnpaid = utc(have[0].UnpaidSince)
113 }
114 row.UnpaidSince = unpaidSince(row.Status, oldUnpaid, row.CurrentPeriodStart, row.CurrentPeriodEnd)
115
116 explicit := adopt && hint.Subject != ""
117
118 if len(have) == 0 {
119 ok, _ := grants(row.Status, row.UnpaidSince, row.CurrentPeriodStart, row.CurrentPeriodEnd, row.CancelAtPeriodEnd, now)
120 if !ok && !adopt {
121 // An ended subscription first seen now (a reconcile reading the
122 // account's history): recording it could only take away access
123 // someone was given another way.
124 return "", nil
125 }
126 // Holder is never asked about a price no offer recognises.
127 if row.Subject, err = s.holder(ctx, tx, sub, hint, plan != ""); err != nil {
128 return "", err
129 }
130 row.SubjectPinned = explicit
131 if err := tx.Exec(`INSERT INTO abono_subscriptions
132 (stripe_subscription_id, stripe_customer_id, subject, subject_pinned, plan, stripe_price_id, status,
133 current_period_start, current_period_end, cancel_at_period_end, unpaid_since, email, created_at, updated_at)
134 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
135 row.StripeSubscriptionID, row.StripeCustomerID, row.Subject, row.SubjectPinned, row.Plan, row.StripePriceID, row.Status,
136 row.CurrentPeriodStart, row.CurrentPeriodEnd, row.CancelAtPeriodEnd, row.UnpaidSince, row.Email, row.CreatedAt, row.UpdatedAt).Error; err != nil {
137 return "", err
138 }
139 if row.Plan != "" {
140 return row.Subject, s.changed(ctx, tx, row.Subject)
141 }
142 return row.Subject, nil
143 }
144
145 old := have[0]
146 row.SubjectPinned = old.SubjectPinned || explicit
147 switch {
148 case explicit:
149 // An owner adopting it for someone: this outranks everything,
150 // including an earlier adoption.
151 row.Subject = hint.Subject
152 if err := mapCustomer(tx, sub.Customer.ID, hint.Subject); err != nil {
153 return "", err
154 }
155 case old.SubjectPinned:
156 // Decided explicitly before (Adopt, Claim): a Checkout reference or
157 // metadata hint from before that decision does not undo it.
158 row.Subject = old.Subject
159 case hint.Subject != "":
160 // The signed-in buyer, or an owner adopting: this beats whoever the
161 // row was first tied to.
162 row.Subject = hint.Subject
163 if err := mapCustomer(tx, sub.Customer.ID, hint.Subject); err != nil {
164 return "", err
165 }
166 case old.Subject != "":
167 row.Subject = old.Subject
168 default:
169 // Not tied to anyone yet: found as a new row's holder is, the
170 // customer's mapping and then, on a known price, Holder for whatever
171 // address this sync has. The row may have been recorded before any
172 // address was known, or Holder may decide differently now; if it
173 // declines again the row stays unclaimed, with the address kept for
174 // Claim.
175 if row.Subject, err = s.holder(ctx, tx, sub, hint, plan != ""); err != nil {
176 return "", err
177 }
178 }
179 if row.Email == "" {
180 row.Email = old.Email
181 }
182 if row.StripeCustomerID == "" {
183 row.StripeCustomerID = old.StripeCustomerID
184 }
185
186 moved := row.Subject != old.Subject || row.Plan != old.Plan || row.Status != old.Status ||
187 row.CancelAtPeriodEnd != old.CancelAtPeriodEnd || !sameTime(row.CurrentPeriodEnd, old.CurrentPeriodEnd) ||
188 !sameTime(row.CurrentPeriodStart, old.CurrentPeriodStart) || !sameTime(row.UnpaidSince, old.UnpaidSince)
189 if !moved && row.StripePriceID == old.StripePriceID && row.StripeCustomerID == old.StripeCustomerID && row.Email == old.Email &&
190 row.SubjectPinned == old.SubjectPinned {
191 return row.Subject, nil
192 }
193 if err := tx.Exec(`UPDATE abono_subscriptions SET
194 stripe_customer_id = ?, subject = ?, subject_pinned = ?, plan = ?, stripe_price_id = ?, status = ?,
195 current_period_start = ?, current_period_end = ?, cancel_at_period_end = ?, unpaid_since = ?, email = ?, updated_at = ?
196 WHERE stripe_subscription_id = ?`,
197 row.StripeCustomerID, row.Subject, row.SubjectPinned, row.Plan, row.StripePriceID, row.Status,
198 row.CurrentPeriodStart, row.CurrentPeriodEnd, row.CancelAtPeriodEnd, row.UnpaidSince, row.Email, now, row.StripeSubscriptionID).Error; err != nil {
199 return "", err
200 }
201 if !moved {
202 return row.Subject, nil
203 }
204 if old.Plan != "" {
205 if err := s.changed(ctx, tx, old.Subject); err != nil {
206 return "", err
207 }
208 }
209 if row.Plan != "" && (old.Plan == "" || old.Subject != row.Subject) {
210 if err := s.changed(ctx, tx, row.Subject); err != nil {
211 return "", err
212 }
213 }
214 return row.Subject, nil
215}
216
217// unpaidSince is a subscription's first unpaid day after a sync that
218// reads status (ruling R32). Paid (active or trialing), there is none.
219// Unpaid (past_due or unpaid), it is the day already recorded, or, the
220// first time the subscription is seen unpaid, where the 30 days count from
221// now: the unpaid period's start, else its end. A later failed renewal
222// moves Stripe's period start on, never this. Any other status keeps what
223// was recorded; it grants nothing either way.
224func unpaidSince(status string, recorded, periodStart, periodEnd *time.Time) *time.Time {
225 switch status {
226 case "active", "trialing":
227 return nil
228 case "past_due", "unpaid":
229 if recorded != nil {
230 return recorded
231 }
232 if anchor, ok := pastDueAnchor(nil, periodStart, periodEnd); ok {
233 return &anchor
234 }
235 return nil
236 }
237 return recorded
238}
239
240// holder is whose a subscription is: the hint's subject, then whoever its
241// Stripe customer is already mapped to, then, if ask, the app's Holder for
242// the customer's address and then the hint's. A subject from the hint or
243// from Holder is recorded as the customer's. "" means nobody yet: the row is
244// recorded unclaimed, by address.
245func (s *Service) holder(ctx context.Context, tx *gorm.DB, sub stripeapi.Subscription, hint Hint, ask bool) (string, error) {
246 if hint.Subject != "" {
247 return hint.Subject, mapCustomer(tx, sub.Customer.ID, hint.Subject)
248 }
249 if sub.Customer.ID != "" {
250 var mapped []string
251 if err := tx.Raw(`SELECT subject FROM abono_customers WHERE stripe_customer_id = ? AND subject <> ''`,
252 sub.Customer.ID).Scan(&mapped).Error; err != nil {
253 return "", err
254 }
255 if len(mapped) > 0 {
256 return mapped[0], nil
257 }
258 }
259 if !ask || s.cfg.Holder == nil {
260 return "", nil
261 }
262 tried := ""
263 for _, addr := range []string{normalEmail(sub.Customer.Email), hint.Email} {
264 if addr == "" || addr == tried {
265 continue
266 }
267 tried = addr
268 subject, err := s.cfg.Holder(ctx, tx, addr, strings.TrimSpace(sub.Customer.Name))
269 if err != nil {
270 return "", err
271 }
272 if subject = strings.TrimSpace(subject); subject != "" {
273 return subject, mapCustomer(tx, sub.Customer.ID, subject)
274 }
275 }
276 return "", nil
277}
278
279// mapCustomer records that customer pays as subject.
280func mapCustomer(tx *gorm.DB, customer, subject string) error {
281 if customer == "" || subject == "" {
282 return nil
283 }
284 return tx.Exec(`INSERT INTO abono_customers (stripe_customer_id, subject) VALUES (?, ?)
285 ON CONFLICT(stripe_customer_id) DO UPDATE SET subject = excluded.subject`, customer, subject).Error
286}
287
288// planOf is the plan price belongs to, sold or legacy, or "".
289func planOf(tx *gorm.DB, price string) (string, error) {
290 if price == "" {
291 return "", nil
292 }
293 var plans []string
294 if err := tx.Raw(`SELECT plan FROM abono_prices WHERE stripe_price_id = ?`, price).Scan(&plans).Error; err != nil {
295 return "", err
296 }
297 if len(plans) == 0 {
298 return "", nil
299 }
300 return plans[0], nil
301}
302
303// firstAddress is the address a subscription was paid with: its customer's,
304// else the one the hint carries.
305func firstAddress(sub stripeapi.Subscription, hint Hint) string {
306 if a := normalEmail(sub.Customer.Email); a != "" {
307 return a
308 }
309 return hint.Email
310}
311
312func normalEmail(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
313
314func sameTime(a, b *time.Time) bool {
315 if a == nil || b == nil {
316 return a == nil && b == nil
317 }
318 return a.Equal(*b)
319}
320
321// Unrecognised is a granting subscription on a price no offer has: the
322// account sells something else too, or a plan's price was never named. The
323// owner's members screen lists these, with an action to adopt one onto a
324// plan.
325type Unrecognised struct {
326 SubscriptionID, CustomerID, PriceID, Subject, Status string
327}
328
329// Unrecognised lists the subscriptions on a price no offer recognises that
330// would grant if they were recognised, oldest first.
331func (s *Service) Unrecognised(ctx context.Context) ([]Unrecognised, error) {
332 var rows []subRecord
333 if err := s.cfg.DB.WithContext(ctx).Where("plan = ''").
334 Order("created_at, stripe_subscription_id").Find(&rows).Error; err != nil {
335 return nil, err
336 }
337 now := s.now()
338 var out []Unrecognised
339 for _, r := range rows {
340 if ok, _ := grants(r.Status, utc(r.UnpaidSince), utc(r.CurrentPeriodStart), utc(r.CurrentPeriodEnd), r.CancelAtPeriodEnd, now); !ok {
341 continue
342 }
343 out = append(out, Unrecognised{
344 SubscriptionID: r.StripeSubscriptionID, CustomerID: r.StripeCustomerID,
345 PriceID: r.StripePriceID, Subject: r.Subject, Status: r.Status,
346 })
347 }
348 return out, nil
349}
350
351// Claim attaches the subscriptions paid for with email and not yet tied to
352// anyone to subject, records their customers as subject's, tells the app
353// (OnChange, in the same transaction), and returns how many it attached.
354// The app calls it once it knows subject owns email, at sign-in say: abono
355// takes its word for that. A claimed subscription's subject is pinned: no
356// later hint changes it (ruling R36).
357func (s *Service) Claim(ctx context.Context, subject, email string) (int, error) {
358 subject, email = strings.TrimSpace(subject), normalEmail(email)
359 if subject == "" || email == "" {
360 return 0, errors.New("abono: Claim needs a subject and an address")
361 }
362 var n int
363 err := s.tx(ctx, func(tx *gorm.DB) error {
364 var rows []subRecord
365 if err := tx.Where("subject = '' AND email = ?", email).Find(&rows).Error; err != nil {
366 return err
367 }
368 tell := false
369 for _, r := range rows {
370 if err := tx.Exec(`UPDATE abono_subscriptions SET subject = ?, subject_pinned = 1, updated_at = ? WHERE stripe_subscription_id = ?`,
371 subject, s.now(), r.StripeSubscriptionID).Error; err != nil {
372 return err
373 }
374 if err := mapCustomer(tx, r.StripeCustomerID, subject); err != nil {
375 return err
376 }
377 tell = tell || r.Plan != ""
378 }
379 n = len(rows)
380 if tell {
381 return s.changed(ctx, tx, subject)
382 }
383 return nil
384 })
385 if err != nil {
386 return 0, err
387 }
388 return n, nil
389}
390