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
8 "gorm.io/gorm"
9
10 "amadan.net/rastrillo/abono/internal/stripeapi"
11)
12
13// Report is what a Reconcile looked at. When Reconcile also returns an
14// error, the Report says what was done all the same: the subscriptions
15// that synced (Subscriptions - Failed of them) are committed, so a page
16// must not say that nothing was synced when that is more than none.
17type Report struct {
18 // Subscriptions is how many Stripe subscriptions were listed and
19 // synced, or tried.
20 Subscriptions int
21 // Failed is how many of them could not be synced.
22 Failed int
23 // Subjects is how many subjects OnChange was run for.
24 Subjects int
25}
26
27// Reconcile is the daily tick's work. It lists every subscription on the
28// Stripe account and, for each, takes its lock, fetches it again and syncs
29// it (ruling R35: a listed snapshot was read outside the lock, and a
30// webhook may have written a newer one since), with what the subscription
31// itself says of
32// its buyer (Checkout's abono_subject metadata, then the customer's
33// address, as a subscription event does), which makes up for a webhook
34// Stripe gave up on (a subscription that fails to sync is reported, by id,
35// and the rest carry on; only the listing itself failing stops the walk); it refreshes every price's interval, amount and
36// currency; and it runs OnChange for every distinct subject that holds any
37// subscription row or comp, each in its own transaction. That last step is
38// how a comp or a paid period that ran out is told to the app, which no
39// event announces.
40//
41// OnChange must therefore be idempotent: Reconcile calls it for subjects
42// whose answer has not moved, a deliberate superset of the ones whose has.
43//
44// The three steps run independently, so a Stripe outage does not stop the
45// sweep for comps that need no Stripe. Their errors are joined. Off (no
46// key), only the sweep runs.
47func (s *Service) Reconcile(ctx context.Context) (Report, error) {
48 var rep Report
49 var errs []error
50 if s.On() {
51 err := s.stripe.ListSubscriptions(ctx, func(listed stripeapi.Subscription) error {
52 rep.Subscriptions++
53 // One subscription failing (the app's Holder or OnChange, say)
54 // must not stop the rest being refreshed on every tick after.
55 if err := s.reconcileOne(ctx, listed.ID); err != nil {
56 rep.Failed++
57 s.noteRefusedKey(err)
58 errs = append(errs, fmt.Errorf("abono: reconcile: subscription %s: %w", listed.ID, err))
59 }
60 return nil
61 })
62 if err != nil {
63 s.noteRefusedKey(err)
64 errs = append(errs, fmt.Errorf("abono: reconcile subscriptions: %w", err))
65 }
66 errs = append(errs, s.refreshPrices(ctx)...)
67 }
68
69 var subjects []string
70 if err := s.cfg.DB.WithContext(ctx).Raw(`SELECT subject FROM abono_subscriptions WHERE subject <> ''
71 UNION SELECT subject FROM abono_comps WHERE subject <> '' ORDER BY subject`).Scan(&subjects).Error; err != nil {
72 return rep, errors.Join(append(errs, fmt.Errorf("abono: reconcile subjects: %w", err))...)
73 }
74 for _, subject := range subjects {
75 err := s.tx(ctx, func(tx *gorm.DB) error { return s.changed(ctx, tx, subject) })
76 if err != nil {
77 errs = append(errs, fmt.Errorf("abono: reconcile: telling %q: %w", subject, err))
78 continue
79 }
80 rep.Subjects++
81 }
82 return rep, errors.Join(errs...)
83}
84
85// reconcileOne fetches subscription id again, inside its lock, and syncs
86// it. One deleted since the listing is skipped.
87func (s *Service) reconcileOne(ctx context.Context, id string) error {
88 unlock, err := s.subs.lock(ctx, id)
89 if err != nil {
90 return err
91 }
92 defer unlock()
93 sub, err := s.stripe.GetSubscription(ctx, id)
94 switch {
95 case stripeapi.NotFound(err):
96 return nil
97 case err != nil:
98 return err
99 }
100 return s.sync(ctx, sub, subscriptionHint(sub))
101}
102
103// refreshPrices reads every known price again. A price that cannot be read
104// is reported and the rest carry on.
105func (s *Service) refreshPrices(ctx context.Context) []error {
106 var ids []string
107 if err := s.cfg.DB.WithContext(ctx).Raw(`SELECT stripe_price_id FROM abono_prices ORDER BY stripe_price_id`).Scan(&ids).Error; err != nil {
108 return []error{fmt.Errorf("abono: reconcile prices: %w", err)}
109 }
110 var errs []error
111 for _, id := range ids {
112 if err := s.refreshPrice(ctx, id); err != nil {
113 s.noteRefusedKey(err)
114 errs = append(errs, err)
115 }
116 }
117 return errs
118}
119