package abono import ( "context" "errors" "strings" "time" "gorm.io/gorm" "amadan.net/rastrillo/abono/internal/stripeapi" ) // Hint is what a caller knows of whose a subscription is, beyond Stripe's // customer: Checkout's client_reference_id (or the subscription's // abono_subject metadata) and the address typed there. // // A hint does not decide whether a row is made: any sync of a subscription // that grants records it (rulings R16 and R16a), so a reconcile recovers a // webhook Stripe gave up on. type Hint struct { Subject, Email string } // subRecord is a whole abono_subscriptions row. type subRecord struct { StripeSubscriptionID string StripeCustomerID string Subject string SubjectPinned bool Plan string StripePriceID string Status string CurrentPeriodStart *time.Time CurrentPeriodEnd *time.Time CancelAtPeriodEnd bool UnpaidSince *time.Time Email string CreatedAt time.Time UpdatedAt time.Time } func (subRecord) TableName() string { return "abono_subscriptions" } // sync makes abono's row for sub match it, in one transaction. The webhook // and the reconcile call it; Finish and Adopt use syncTx inside their own. func (s *Service) sync(ctx context.Context, sub stripeapi.Subscription, hint Hint) error { return s.tx(ctx, func(tx *gorm.DB) error { _, err := s.syncTx(ctx, tx, sub, hint, false) return err }) } // syncTx is sync inside tx, and returns the row's subject afterwards ("" for // none, or for a payment not yet tied to anyone). // // The row is read again by subscription id first, inside the transaction. // SQLite has one writer, so two syncs of the same new subscription (Checkout's // return and its webhook, say) run one after the other: the second finds the // first's row and updates it, and Holder is asked at most once. Holder is // only ever called from here, inside tx. // // With no row yet, one is made when the subscription grants, or when adopt // is set (an owner said so). adopt with a hint subject is an explicit // decision of whose the subscription is: it pins the row's subject, and a // pinned subject is never changed by a hint after (ruling R36), only by // another adoption. Claim pins too. An ended subscription first seen now is never // recorded (the spec's "no resurrection" rule). The plan is the one whose // prices include the subscription's first price, or "" when none does: such // a subscription is recorded as unrecognised, never grants, and is listed // by Unrecognised for an owner to adopt; Holder is not asked about it. // // OnChange runs for the subject before and after whenever the subject, // plan, status, period end or cancel flag changed, but only for a side that // has a plan: an unrecognised row, like an unclaimed one, tells nobody. func (s *Service) syncTx(ctx context.Context, tx *gorm.DB, sub stripeapi.Subscription, hint Hint, adopt bool) (string, error) { if sub.ID == "" { return "", errors.New("abono: sync: a subscription with no id") } hint.Subject = strings.TrimSpace(hint.Subject) hint.Email = normalEmail(hint.Email) var have []subRecord if err := tx.Where("stripe_subscription_id = ?", sub.ID).Limit(1).Find(&have).Error; err != nil { return "", err } price, _ := sub.PriceID() plan, err := planOf(tx, price) if err != nil { return "", err } now := s.now() row := subRecord{ StripeSubscriptionID: sub.ID, StripeCustomerID: sub.Customer.ID, Plan: plan, StripePriceID: price, Status: sub.Status, CancelAtPeriodEnd: sub.CancelAtPeriodEnd, Email: firstAddress(sub, hint), CreatedAt: now, UpdatedAt: now, } if start := sub.PeriodStart(); !start.IsZero() { row.CurrentPeriodStart = &start } if end := sub.PeriodEnd(); !end.IsZero() { row.CurrentPeriodEnd = &end } var oldUnpaid *time.Time if len(have) > 0 { oldUnpaid = utc(have[0].UnpaidSince) } row.UnpaidSince = unpaidSince(row.Status, oldUnpaid, row.CurrentPeriodStart, row.CurrentPeriodEnd) explicit := adopt && hint.Subject != "" if len(have) == 0 { ok, _ := grants(row.Status, row.UnpaidSince, row.CurrentPeriodStart, row.CurrentPeriodEnd, row.CancelAtPeriodEnd, now) if !ok && !adopt { // An ended subscription first seen now (a reconcile reading the // account's history): recording it could only take away access // someone was given another way. return "", nil } // Holder is never asked about a price no offer recognises. if row.Subject, err = s.holder(ctx, tx, sub, hint, plan != ""); err != nil { return "", err } row.SubjectPinned = explicit if err := tx.Exec(`INSERT INTO abono_subscriptions (stripe_subscription_id, stripe_customer_id, subject, subject_pinned, plan, stripe_price_id, status, current_period_start, current_period_end, cancel_at_period_end, unpaid_since, email, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, row.StripeSubscriptionID, row.StripeCustomerID, row.Subject, row.SubjectPinned, row.Plan, row.StripePriceID, row.Status, row.CurrentPeriodStart, row.CurrentPeriodEnd, row.CancelAtPeriodEnd, row.UnpaidSince, row.Email, row.CreatedAt, row.UpdatedAt).Error; err != nil { return "", err } if row.Plan != "" { return row.Subject, s.changed(ctx, tx, row.Subject) } return row.Subject, nil } old := have[0] row.SubjectPinned = old.SubjectPinned || explicit switch { case explicit: // An owner adopting it for someone: this outranks everything, // including an earlier adoption. row.Subject = hint.Subject if err := mapCustomer(tx, sub.Customer.ID, hint.Subject); err != nil { return "", err } case old.SubjectPinned: // Decided explicitly before (Adopt, Claim): a Checkout reference or // metadata hint from before that decision does not undo it. row.Subject = old.Subject case hint.Subject != "": // The signed-in buyer, or an owner adopting: this beats whoever the // row was first tied to. row.Subject = hint.Subject if err := mapCustomer(tx, sub.Customer.ID, hint.Subject); err != nil { return "", err } case old.Subject != "": row.Subject = old.Subject default: // Not tied to anyone yet: found as a new row's holder is, the // customer's mapping and then, on a known price, Holder for whatever // address this sync has. The row may have been recorded before any // address was known, or Holder may decide differently now; if it // declines again the row stays unclaimed, with the address kept for // Claim. if row.Subject, err = s.holder(ctx, tx, sub, hint, plan != ""); err != nil { return "", err } } if row.Email == "" { row.Email = old.Email } if row.StripeCustomerID == "" { row.StripeCustomerID = old.StripeCustomerID } moved := row.Subject != old.Subject || row.Plan != old.Plan || row.Status != old.Status || row.CancelAtPeriodEnd != old.CancelAtPeriodEnd || !sameTime(row.CurrentPeriodEnd, old.CurrentPeriodEnd) || !sameTime(row.CurrentPeriodStart, old.CurrentPeriodStart) || !sameTime(row.UnpaidSince, old.UnpaidSince) if !moved && row.StripePriceID == old.StripePriceID && row.StripeCustomerID == old.StripeCustomerID && row.Email == old.Email && row.SubjectPinned == old.SubjectPinned { return row.Subject, nil } if err := tx.Exec(`UPDATE abono_subscriptions SET stripe_customer_id = ?, subject = ?, subject_pinned = ?, plan = ?, stripe_price_id = ?, status = ?, current_period_start = ?, current_period_end = ?, cancel_at_period_end = ?, unpaid_since = ?, email = ?, updated_at = ? WHERE stripe_subscription_id = ?`, row.StripeCustomerID, row.Subject, row.SubjectPinned, row.Plan, row.StripePriceID, row.Status, row.CurrentPeriodStart, row.CurrentPeriodEnd, row.CancelAtPeriodEnd, row.UnpaidSince, row.Email, now, row.StripeSubscriptionID).Error; err != nil { return "", err } if !moved { return row.Subject, nil } if old.Plan != "" { if err := s.changed(ctx, tx, old.Subject); err != nil { return "", err } } if row.Plan != "" && (old.Plan == "" || old.Subject != row.Subject) { if err := s.changed(ctx, tx, row.Subject); err != nil { return "", err } } return row.Subject, nil } // unpaidSince is a subscription's first unpaid day after a sync that // reads status (ruling R32). Paid (active or trialing), there is none. // Unpaid (past_due or unpaid), it is the day already recorded, or, the // first time the subscription is seen unpaid, where the 30 days count from // now: the unpaid period's start, else its end. A later failed renewal // moves Stripe's period start on, never this. Any other status keeps what // was recorded; it grants nothing either way. func unpaidSince(status string, recorded, periodStart, periodEnd *time.Time) *time.Time { switch status { case "active", "trialing": return nil case "past_due", "unpaid": if recorded != nil { return recorded } if anchor, ok := pastDueAnchor(nil, periodStart, periodEnd); ok { return &anchor } return nil } return recorded } // holder is whose a subscription is: the hint's subject, then whoever its // Stripe customer is already mapped to, then, if ask, the app's Holder for // the customer's address and then the hint's. A subject from the hint or // from Holder is recorded as the customer's. "" means nobody yet: the row is // recorded unclaimed, by address. func (s *Service) holder(ctx context.Context, tx *gorm.DB, sub stripeapi.Subscription, hint Hint, ask bool) (string, error) { if hint.Subject != "" { return hint.Subject, mapCustomer(tx, sub.Customer.ID, hint.Subject) } if sub.Customer.ID != "" { var mapped []string if err := tx.Raw(`SELECT subject FROM abono_customers WHERE stripe_customer_id = ? AND subject <> ''`, sub.Customer.ID).Scan(&mapped).Error; err != nil { return "", err } if len(mapped) > 0 { return mapped[0], nil } } if !ask || s.cfg.Holder == nil { return "", nil } tried := "" for _, addr := range []string{normalEmail(sub.Customer.Email), hint.Email} { if addr == "" || addr == tried { continue } tried = addr subject, err := s.cfg.Holder(ctx, tx, addr, strings.TrimSpace(sub.Customer.Name)) if err != nil { return "", err } if subject = strings.TrimSpace(subject); subject != "" { return subject, mapCustomer(tx, sub.Customer.ID, subject) } } return "", nil } // mapCustomer records that customer pays as subject. func mapCustomer(tx *gorm.DB, customer, subject string) error { if customer == "" || subject == "" { return nil } return tx.Exec(`INSERT INTO abono_customers (stripe_customer_id, subject) VALUES (?, ?) ON CONFLICT(stripe_customer_id) DO UPDATE SET subject = excluded.subject`, customer, subject).Error } // planOf is the plan price belongs to, sold or legacy, or "". func planOf(tx *gorm.DB, price string) (string, error) { if price == "" { return "", nil } var plans []string if err := tx.Raw(`SELECT plan FROM abono_prices WHERE stripe_price_id = ?`, price).Scan(&plans).Error; err != nil { return "", err } if len(plans) == 0 { return "", nil } return plans[0], nil } // firstAddress is the address a subscription was paid with: its customer's, // else the one the hint carries. func firstAddress(sub stripeapi.Subscription, hint Hint) string { if a := normalEmail(sub.Customer.Email); a != "" { return a } return hint.Email } func normalEmail(s string) string { return strings.ToLower(strings.TrimSpace(s)) } func sameTime(a, b *time.Time) bool { if a == nil || b == nil { return a == nil && b == nil } return a.Equal(*b) } // Unrecognised is a granting subscription on a price no offer has: the // account sells something else too, or a plan's price was never named. The // owner's members screen lists these, with an action to adopt one onto a // plan. type Unrecognised struct { SubscriptionID, CustomerID, PriceID, Subject, Status string } // Unrecognised lists the subscriptions on a price no offer recognises that // would grant if they were recognised, oldest first. func (s *Service) Unrecognised(ctx context.Context) ([]Unrecognised, error) { var rows []subRecord if err := s.cfg.DB.WithContext(ctx).Where("plan = ''"). Order("created_at, stripe_subscription_id").Find(&rows).Error; err != nil { return nil, err } now := s.now() var out []Unrecognised for _, r := range rows { if ok, _ := grants(r.Status, utc(r.UnpaidSince), utc(r.CurrentPeriodStart), utc(r.CurrentPeriodEnd), r.CancelAtPeriodEnd, now); !ok { continue } out = append(out, Unrecognised{ SubscriptionID: r.StripeSubscriptionID, CustomerID: r.StripeCustomerID, PriceID: r.StripePriceID, Subject: r.Subject, Status: r.Status, }) } return out, nil } // Claim attaches the subscriptions paid for with email and not yet tied to // anyone to subject, records their customers as subject's, tells the app // (OnChange, in the same transaction), and returns how many it attached. // The app calls it once it knows subject owns email, at sign-in say: abono // takes its word for that. A claimed subscription's subject is pinned: no // later hint changes it (ruling R36). func (s *Service) Claim(ctx context.Context, subject, email string) (int, error) { subject, email = strings.TrimSpace(subject), normalEmail(email) if subject == "" || email == "" { return 0, errors.New("abono: Claim needs a subject and an address") } var n int err := s.tx(ctx, func(tx *gorm.DB) error { var rows []subRecord if err := tx.Where("subject = '' AND email = ?", email).Find(&rows).Error; err != nil { return err } tell := false for _, r := range rows { if err := tx.Exec(`UPDATE abono_subscriptions SET subject = ?, subject_pinned = 1, updated_at = ? WHERE stripe_subscription_id = ?`, subject, s.now(), r.StripeSubscriptionID).Error; err != nil { return err } if err := mapCustomer(tx, r.StripeCustomerID, subject); err != nil { return err } tell = tell || r.Plan != "" } n = len(rows) if tell { return s.changed(ctx, tx, subject) } return nil }) if err != nil { return 0, err } return n, nil }