| 1 | package abono |
| 2 | |
| 3 | import ( |
| 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. |
| 21 | type Hint struct { |
| 22 | Subject, Email string |
| 23 | } |
| 24 | |
| 25 | // subRecord is a whole abono_subscriptions row. |
| 26 | type 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 | |
| 43 | func (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. |
| 47 | func (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. |
| 76 | func (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. |
| 224 | func 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. |
| 245 | func (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. |
| 280 | func 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 "". |
| 289 | func 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. |
| 305 | func 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 | |
| 312 | func normalEmail(s string) string { return strings.ToLower(strings.TrimSpace(s)) } |
| 313 | |
| 314 | func 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. |
| 325 | type 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. |
| 331 | func (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). |
| 357 | func (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 | |