package abono import ( "context" "errors" "fmt" "gorm.io/gorm" "amadan.net/rastrillo/abono/internal/stripeapi" ) // Report is what a Reconcile looked at. When Reconcile also returns an // error, the Report says what was done all the same: the subscriptions // that synced (Subscriptions - Failed of them) are committed, so a page // must not say that nothing was synced when that is more than none. type Report struct { // Subscriptions is how many Stripe subscriptions were listed and // synced, or tried. Subscriptions int // Failed is how many of them could not be synced. Failed int // Subjects is how many subjects OnChange was run for. Subjects int } // Reconcile is the daily tick's work. It lists every subscription on the // Stripe account and, for each, takes its lock, fetches it again and syncs // it (ruling R35: a listed snapshot was read outside the lock, and a // webhook may have written a newer one since), with what the subscription // itself says of // its buyer (Checkout's abono_subject metadata, then the customer's // address, as a subscription event does), which makes up for a webhook // Stripe gave up on (a subscription that fails to sync is reported, by id, // and the rest carry on; only the listing itself failing stops the walk); it refreshes every price's interval, amount and // currency; and it runs OnChange for every distinct subject that holds any // subscription row or comp, each in its own transaction. That last step is // how a comp or a paid period that ran out is told to the app, which no // event announces. // // OnChange must therefore be idempotent: Reconcile calls it for subjects // whose answer has not moved, a deliberate superset of the ones whose has. // // The three steps run independently, so a Stripe outage does not stop the // sweep for comps that need no Stripe. Their errors are joined. Off (no // key), only the sweep runs. func (s *Service) Reconcile(ctx context.Context) (Report, error) { var rep Report var errs []error if s.On() { err := s.stripe.ListSubscriptions(ctx, func(listed stripeapi.Subscription) error { rep.Subscriptions++ // One subscription failing (the app's Holder or OnChange, say) // must not stop the rest being refreshed on every tick after. if err := s.reconcileOne(ctx, listed.ID); err != nil { rep.Failed++ s.noteRefusedKey(err) errs = append(errs, fmt.Errorf("abono: reconcile: subscription %s: %w", listed.ID, err)) } return nil }) if err != nil { s.noteRefusedKey(err) errs = append(errs, fmt.Errorf("abono: reconcile subscriptions: %w", err)) } errs = append(errs, s.refreshPrices(ctx)...) } var subjects []string if err := s.cfg.DB.WithContext(ctx).Raw(`SELECT subject FROM abono_subscriptions WHERE subject <> '' UNION SELECT subject FROM abono_comps WHERE subject <> '' ORDER BY subject`).Scan(&subjects).Error; err != nil { return rep, errors.Join(append(errs, fmt.Errorf("abono: reconcile subjects: %w", err))...) } for _, subject := range subjects { err := s.tx(ctx, func(tx *gorm.DB) error { return s.changed(ctx, tx, subject) }) if err != nil { errs = append(errs, fmt.Errorf("abono: reconcile: telling %q: %w", subject, err)) continue } rep.Subjects++ } return rep, errors.Join(errs...) } // reconcileOne fetches subscription id again, inside its lock, and syncs // it. One deleted since the listing is skipped. func (s *Service) reconcileOne(ctx context.Context, id string) error { unlock, err := s.subs.lock(ctx, id) if err != nil { return err } defer unlock() sub, err := s.stripe.GetSubscription(ctx, id) switch { case stripeapi.NotFound(err): return nil case err != nil: return err } return s.sync(ctx, sub, subscriptionHint(sub)) } // refreshPrices reads every known price again. A price that cannot be read // is reported and the rest carry on. func (s *Service) refreshPrices(ctx context.Context) []error { var ids []string 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 { return []error{fmt.Errorf("abono: reconcile prices: %w", err)} } var errs []error for _, id := range ids { if err := s.refreshPrice(ctx, id); err != nil { s.noteRefusedKey(err) errs = append(errs, err) } } return errs }