| 1 | package abono_test |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "net/http" |
| 7 | "reflect" |
| 8 | "strings" |
| 9 | "sync" |
| 10 | "sync/atomic" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "amadan.net/rastrillo/abono" |
| 15 | ) |
| 16 | |
| 17 | const rowsQuery = `SELECT stripe_subscription_id, stripe_customer_id, subject, plan, stripe_price_id, status, cancel_at_period_end, email |
| 18 | FROM abono_subscriptions ORDER BY stripe_subscription_id` |
| 19 | |
| 20 | // seedPlans sells annual (price_A, with legacy price_old) and monthly |
| 21 | // (price_M). |
| 22 | func seedPlans(t *testing.T, s *abono.Service) { |
| 23 | t.Helper() |
| 24 | if err := s.SeedPlans(context.Background(), []abono.PlanSpec{ |
| 25 | {Plan: "annual", Sold: "price_A", Legacy: []string{"price_old"}}, |
| 26 | {Plan: "monthly", Sold: "price_M"}, |
| 27 | }); err != nil { |
| 28 | t.Fatalf("SeedPlans: %v", err) |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | func hasPlan(t *testing.T, s *abono.Service, subject, plan string) bool { |
| 33 | t.Helper() |
| 34 | ok, err := s.HasPlan(context.Background(), subject, plan) |
| 35 | if err != nil { |
| 36 | t.Fatalf("HasPlan(%q, %q): %v", subject, plan, err) |
| 37 | } |
| 38 | return ok |
| 39 | } |
| 40 | |
| 41 | func eq(t *testing.T, what string, got, want []string) { |
| 42 | t.Helper() |
| 43 | if len(got) == 0 && len(want) == 0 { |
| 44 | return |
| 45 | } |
| 46 | if !reflect.DeepEqual(got, want) { |
| 47 | t.Errorf("%s = %q, want %q", what, got, want) |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | // TestSyncCreatesOnlyWithAHint is the creation rule (spec 4.1, rulings R16 |
| 52 | // and R16a): with no hint, a subscription that grants is recorded, so a |
| 53 | // reconcile recovers a webhook Stripe gave up on. On a price no offer has |
| 54 | // it is recorded unrecognised, for the owner to adopt, and Holder is never |
| 55 | // asked. An ended subscription first seen now is never recorded. |
| 56 | func TestSyncCreatesOnlyWithAHint(t *testing.T) { |
| 57 | now := t0 |
| 58 | d := newStripeDouble(t) |
| 59 | rec := &recorder{subject: "42"} |
| 60 | s, db := d.service(t, &now, rec.config()) |
| 61 | seedPlans(t, s) |
| 62 | ctx := context.Background() |
| 63 | |
| 64 | // Ended, on a known price: nothing, with or without a hint. |
| 65 | ended := d.put(t, subSpec{ID: "sub_0", Status: "canceled", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(-day)}) |
| 66 | for _, hint := range []abono.Hint{{}, {Email: "ana@example.com"}} { |
| 67 | if err := s.Sync(ctx, ended, hint); err != nil { |
| 68 | t.Fatalf("Sync of an ended subscription, hint %+v: %v", hint, err) |
| 69 | } |
| 70 | } |
| 71 | if got := db.dump(rowsQuery); got != "" { |
| 72 | t.Fatalf("an ended subscription first seen now was recorded:\n%s", got) |
| 73 | } |
| 74 | |
| 75 | // Granting, on a price no offer has, no hint: recorded unrecognised and |
| 76 | // tied to nobody, without asking Holder or telling anyone. |
| 77 | other := d.put(t, subSpec{ID: "sub_z", Status: "active", Price: "price_Z", Customer: "cus_9", Email: "zed@example.com", End: t0.Add(30 * day)}) |
| 78 | if err := s.Sync(ctx, other, abono.Hint{}); err != nil { |
| 79 | t.Fatalf("Sync of an unknown price with no hint: %v", err) |
| 80 | } |
| 81 | if got, want := db.dump(rowsQuery), "sub_z|cus_9|||price_Z|active|0|zed@example.com\n"; got != want { |
| 82 | t.Fatalf("rows =\n%s\nwant\n%s", got, want) |
| 83 | } |
| 84 | asked, changes := rec.take() |
| 85 | eq(t, "Holder asked", asked, nil) |
| 86 | eq(t, "OnChange", changes, nil) |
| 87 | if un, err := s.Unrecognised(ctx); err != nil || len(un) != 1 || un[0].SubscriptionID != "sub_z" { |
| 88 | t.Errorf("Unrecognised = %+v, %v; want sub_z", un, err) |
| 89 | } |
| 90 | // Ended, on a price no offer has, no hint: nothing. |
| 91 | gone := d.put(t, subSpec{ID: "sub_y", Status: "canceled", Price: "price_Z", Customer: "cus_9", Email: "zed@example.com", End: t0.Add(-day)}) |
| 92 | if err := s.Sync(ctx, gone, abono.Hint{}); err != nil { |
| 93 | t.Fatalf("Sync of an ended unknown price: %v", err) |
| 94 | } |
| 95 | if got := db.dump("SELECT * FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_y'"); got != "" { |
| 96 | t.Errorf("an ended unknown-price subscription was recorded: %q", got) |
| 97 | } |
| 98 | |
| 99 | // Granting, on a known price, no hint: recovered, its holder found by |
| 100 | // the customer's address. |
| 101 | sub := d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "Ana@Example.com", End: t0.Add(300 * day)}) |
| 102 | if err := s.Sync(ctx, sub, abono.Hint{}); err != nil { |
| 103 | t.Fatalf("Sync with no hint: %v", err) |
| 104 | } |
| 105 | if got, want := db.dump(rowsQuery), "sub_1|cus_1|42|annual|price_A|active|0|ana@example.com\n"+ |
| 106 | "sub_z|cus_9|||price_Z|active|0|zed@example.com\n"; got != want { |
| 107 | t.Fatalf("rows =\n%s\nwant\n%s", got, want) |
| 108 | } |
| 109 | if got := db.dump("SELECT stripe_customer_id, subject FROM abono_customers"); got != "cus_1|42\n" { |
| 110 | t.Errorf("customers = %q, want the Holder's subject recorded", got) |
| 111 | } |
| 112 | asked, changes = rec.take() |
| 113 | eq(t, "Holder asked", asked, []string{"ana@example.com"}) |
| 114 | eq(t, "OnChange", changes, []string{"42"}) |
| 115 | |
| 116 | // A legacy price is a known price too, and a mapped customer decides |
| 117 | // before Holder is asked. |
| 118 | legacy := d.put(t, subSpec{ID: "sub_L", Status: "active", Price: "price_old", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day)}) |
| 119 | if err := s.Sync(ctx, legacy, abono.Hint{}); err != nil { |
| 120 | t.Fatalf("Sync of a legacy price: %v", err) |
| 121 | } |
| 122 | if got := db.dump("SELECT subject, plan FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_L'"); got != "42|annual\n" { |
| 123 | t.Errorf("legacy row = %q, want 42|annual", got) |
| 124 | } |
| 125 | asked, changes = rec.take() |
| 126 | eq(t, "Holder asked for a mapped customer", asked, nil) |
| 127 | eq(t, "OnChange for the legacy row", changes, []string{"42"}) |
| 128 | |
| 129 | // An unknown price of a mapped customer is tied to them, still |
| 130 | // unrecognised, and tells nobody. |
| 131 | mapped := d.put(t, subSpec{ID: "sub_z2", Status: "active", Price: "price_Z", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day)}) |
| 132 | if err := s.Sync(ctx, mapped, abono.Hint{}); err != nil { |
| 133 | t.Fatalf("Sync of a mapped customer's unknown price: %v", err) |
| 134 | } |
| 135 | if got := db.dump("SELECT subject, plan FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_z2'"); got != "42|\n" { |
| 136 | t.Errorf("sub_z2 = %q, want 42 and no plan", got) |
| 137 | } |
| 138 | asked, changes = rec.take() |
| 139 | eq(t, "Holder asked for an unknown price", asked, nil) |
| 140 | eq(t, "OnChange for an unknown price", changes, nil) |
| 141 | if !hasPlan(t, s, "42", "annual") { |
| 142 | t.Error("HasPlan(42, annual) is false after the recovered sync") |
| 143 | } |
| 144 | |
| 145 | // The same subscription again changes nothing and tells nobody. |
| 146 | if err := s.Sync(ctx, sub, abono.Hint{}); err != nil { |
| 147 | t.Fatalf("Sync again: %v", err) |
| 148 | } |
| 149 | asked, changes = rec.take() |
| 150 | eq(t, "Holder asked on a repeat", asked, nil) |
| 151 | eq(t, "OnChange on a repeat", changes, nil) |
| 152 | |
| 153 | // An existing row follows Stripe with no hint. |
| 154 | sub = d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(300 * day), Cancel: true}) |
| 155 | if err := s.Sync(ctx, sub, abono.Hint{}); err != nil { |
| 156 | t.Fatalf("Sync of a cancelling subscription: %v", err) |
| 157 | } |
| 158 | if got := db.dump("SELECT cancel_at_period_end FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_1'"); got != "1\n" { |
| 159 | t.Errorf("cancel_at_period_end = %q, want 1", got) |
| 160 | } |
| 161 | _, changes = rec.take() |
| 162 | eq(t, "OnChange after cancelling", changes, []string{"42"}) |
| 163 | } |
| 164 | |
| 165 | func TestSyncUnknownPriceIsUnrecognised(t *testing.T) { |
| 166 | now := t0 |
| 167 | d := newStripeDouble(t) |
| 168 | rec := &recorder{subject: "42"} |
| 169 | s, db := d.service(t, &now, rec.config()) |
| 170 | seedPlans(t, s) |
| 171 | ctx := context.Background() |
| 172 | |
| 173 | sub := d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_Z", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day)}) |
| 174 | if err := s.Sync(ctx, sub, abono.Hint{Email: "ana@example.com"}); err != nil { |
| 175 | t.Fatalf("Sync: %v", err) |
| 176 | } |
| 177 | if got, want := db.dump(rowsQuery), "sub_1|cus_1|||price_Z|active|0|ana@example.com\n"; got != want { |
| 178 | t.Fatalf("rows =\n%s\nwant\n%s", got, want) |
| 179 | } |
| 180 | asked, changes := rec.take() |
| 181 | eq(t, "Holder asked for an unrecognised price", asked, nil) |
| 182 | eq(t, "OnChange for an unrecognised price", changes, nil) |
| 183 | |
| 184 | un, err := s.Unrecognised(ctx) |
| 185 | if err != nil { |
| 186 | t.Fatalf("Unrecognised: %v", err) |
| 187 | } |
| 188 | want := []abono.Unrecognised{{SubscriptionID: "sub_1", CustomerID: "cus_1", PriceID: "price_Z", Status: "active"}} |
| 189 | if !reflect.DeepEqual(un, want) { |
| 190 | t.Errorf("Unrecognised = %+v, want %+v", un, want) |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | // TestPortalPlanSwitchMovesThePlan is Review Focus 2: a reader switching |
| 195 | // plan in the Customer Portal keeps the same subscription id on a new |
| 196 | // price. |
| 197 | func TestPortalPlanSwitchMovesThePlan(t *testing.T) { |
| 198 | now := t0 |
| 199 | d := newStripeDouble(t) |
| 200 | rec := &recorder{} |
| 201 | s, db := d.service(t, &now, rec.config()) |
| 202 | seedPlans(t, s) |
| 203 | ctx := context.Background() |
| 204 | |
| 205 | spec := subSpec{ID: "sub_1", Status: "active", Price: "price_M", Interval: "month", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(20 * day)} |
| 206 | if err := s.Sync(ctx, d.put(t, spec), abono.Hint{Subject: "5"}); err != nil { |
| 207 | t.Fatalf("Sync monthly: %v", err) |
| 208 | } |
| 209 | if !hasPlan(t, s, "5", "monthly") { |
| 210 | t.Fatal("HasPlan(5, monthly) is false") |
| 211 | } |
| 212 | rec.take() |
| 213 | |
| 214 | spec.Price, spec.Interval = "price_A", "year" |
| 215 | if err := s.Sync(ctx, d.put(t, spec), abono.Hint{}); err != nil { |
| 216 | t.Fatalf("Sync annual: %v", err) |
| 217 | } |
| 218 | if got := db.dump("SELECT plan, stripe_price_id FROM abono_subscriptions"); got != "annual|price_A\n" { |
| 219 | t.Errorf("after the switch the row is %q, want annual|price_A", got) |
| 220 | } |
| 221 | if !hasPlan(t, s, "5", "annual") || hasPlan(t, s, "5", "monthly") { |
| 222 | t.Error("after the switch, want annual and not monthly") |
| 223 | } |
| 224 | _, changes := rec.take() |
| 225 | eq(t, "OnChange after the switch", changes, []string{"5"}) |
| 226 | |
| 227 | spec.Price = "price_Z" |
| 228 | if err := s.Sync(ctx, d.put(t, spec), abono.Hint{}); err != nil { |
| 229 | t.Fatalf("Sync onto an unknown price: %v", err) |
| 230 | } |
| 231 | if hasPlan(t, s, "5", "annual") { |
| 232 | t.Error("an unknown price still grants annual") |
| 233 | } |
| 234 | if got := db.dump("SELECT subject, plan, stripe_price_id FROM abono_subscriptions"); got != "5||price_Z\n" { |
| 235 | t.Errorf("row = %q, want 5||price_Z", got) |
| 236 | } |
| 237 | un, err := s.Unrecognised(ctx) |
| 238 | if err != nil || len(un) != 1 || un[0].Subject != "5" { |
| 239 | t.Errorf("Unrecognised = %+v, %v; want sub_1 for subject 5", un, err) |
| 240 | } |
| 241 | // Losing the plan is a change to subject 5's holdings, so the app |
| 242 | // hears of it. |
| 243 | _, changes = rec.take() |
| 244 | eq(t, "OnChange after moving to an unknown price", changes, []string{"5"}) |
| 245 | } |
| 246 | |
| 247 | // TestHintSubjectBeatsAddress is Review Focus 3. |
| 248 | func TestHintSubjectBeatsAddress(t *testing.T) { |
| 249 | now := t0 |
| 250 | d := newStripeDouble(t) |
| 251 | rec := &recorder{subject: "9"} |
| 252 | s, db := d.service(t, &now, rec.config()) |
| 253 | seedPlans(t, s) |
| 254 | ctx := context.Background() |
| 255 | |
| 256 | sub := d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "nine@example.com", End: t0.Add(30 * day)}) |
| 257 | if err := s.Sync(ctx, sub, abono.Hint{Subject: "7", Email: "nine@example.com"}); err != nil { |
| 258 | t.Fatalf("Sync: %v", err) |
| 259 | } |
| 260 | if got := db.dump("SELECT subject FROM abono_subscriptions"); got != "7\n" { |
| 261 | t.Errorf("subject = %q, want 7", got) |
| 262 | } |
| 263 | asked, changes := rec.take() |
| 264 | eq(t, "Holder asked", asked, nil) |
| 265 | eq(t, "OnChange", changes, []string{"7"}) |
| 266 | if !hasPlan(t, s, "7", "annual") || hasPlan(t, s, "9", "annual") { |
| 267 | t.Error("want 7 to hold annual and 9 not") |
| 268 | } |
| 269 | |
| 270 | // A row first made from the address is corrected by a later hint, and |
| 271 | // both subjects hear of it. |
| 272 | other := d.put(t, subSpec{ID: "sub_2", Status: "active", Price: "price_M", Customer: "cus_2", Email: "nine@example.com", End: t0.Add(30 * day)}) |
| 273 | if err := s.Sync(ctx, other, abono.Hint{Email: "nine@example.com"}); err != nil { |
| 274 | t.Fatalf("Sync sub_2 by address: %v", err) |
| 275 | } |
| 276 | if got := db.dump("SELECT subject FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_2'"); got != "9\n" { |
| 277 | t.Fatalf("sub_2 subject = %q, want 9 from Holder", got) |
| 278 | } |
| 279 | rec.take() |
| 280 | if err := s.Sync(ctx, other, abono.Hint{Subject: "7"}); err != nil { |
| 281 | t.Fatalf("Sync sub_2 with a subject: %v", err) |
| 282 | } |
| 283 | if got := db.dump("SELECT subject FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_2'"); got != "7\n" { |
| 284 | t.Errorf("sub_2 subject = %q, want 7 after the hint", got) |
| 285 | } |
| 286 | asked, changes = rec.take() |
| 287 | eq(t, "Holder asked on correction", asked, nil) |
| 288 | eq(t, "OnChange on correction", changes, []string{"9", "7"}) |
| 289 | if hasPlan(t, s, "9", "monthly") || !hasPlan(t, s, "7", "monthly") { |
| 290 | t.Error("after the correction, want 7 to hold monthly and 9 not") |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | // TestFinishAndWebhookRace is Review Focus 1: Checkout's return and the |
| 295 | // webhook sync the same new subscription, in either order or at once, |
| 296 | // and make one row and at most one person. |
| 297 | func TestFinishAndWebhookRace(t *testing.T) { |
| 298 | run := func(t *testing.T, order string) { |
| 299 | now := t0 |
| 300 | d := newStripeDouble(t) |
| 301 | rec := &recorder{subject: "42"} |
| 302 | s, db := d.service(t, &now, rec.config()) |
| 303 | seedPlans(t, s) |
| 304 | ctx := context.Background() |
| 305 | |
| 306 | sub := d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day)}) |
| 307 | d.session("cs_1", "complete", "sub_1", "", "ana@example.com") |
| 308 | hint := abono.Hint{Email: "ana@example.com"} |
| 309 | |
| 310 | finish := func() error { |
| 311 | f, err := s.Finish(ctx, "cs_1") |
| 312 | if err == nil && (!f.Paid || f.Subject != "42") { |
| 313 | t.Errorf("Finish = %+v, want paid by 42", f) |
| 314 | } |
| 315 | return err |
| 316 | } |
| 317 | webhook := func() error { return s.Sync(ctx, sub, hint) } |
| 318 | |
| 319 | var errs [2]error |
| 320 | switch order { |
| 321 | case "finish first": |
| 322 | errs[0], errs[1] = finish(), webhook() |
| 323 | case "webhook first": |
| 324 | errs[1], errs[0] = webhook(), finish() |
| 325 | case "at once": |
| 326 | // The webhook needs no round trip to Stripe and would always |
| 327 | // finish before Finish has read anything. Holding Holder until |
| 328 | // Finish has fetched the subscription puts Finish's read in the |
| 329 | // middle of the webhook's write, which is the race. |
| 330 | fetched := make(chan struct{}) |
| 331 | var once sync.Once |
| 332 | d.OnSubscriptionFetch(func() { once.Do(func() { close(fetched) }) }) |
| 333 | rec.pause = func() { |
| 334 | select { |
| 335 | case <-fetched: |
| 336 | case <-time.After(2 * time.Second): |
| 337 | } |
| 338 | time.Sleep(20 * time.Millisecond) |
| 339 | } |
| 340 | var wg sync.WaitGroup |
| 341 | wg.Add(2) |
| 342 | go func() { defer wg.Done(); errs[0] = finish() }() |
| 343 | go func() { defer wg.Done(); errs[1] = webhook() }() |
| 344 | wg.Wait() |
| 345 | } |
| 346 | for _, err := range errs { |
| 347 | if err != nil { |
| 348 | t.Fatalf("%s: %v", order, err) |
| 349 | } |
| 350 | } |
| 351 | if got, want := db.dump(rowsQuery), "sub_1|cus_1|42|annual|price_A|active|0|ana@example.com\n"; got != want { |
| 352 | t.Errorf("%s: rows =\n%s\nwant\n%s", order, got, want) |
| 353 | } |
| 354 | if asked, _ := rec.take(); len(asked) > 1 { |
| 355 | t.Errorf("%s: Holder called %d times, want at most once", order, len(asked)) |
| 356 | } |
| 357 | } |
| 358 | for _, order := range []string{"finish first", "webhook first"} { |
| 359 | t.Run(order, func(t *testing.T) { run(t, order) }) |
| 360 | } |
| 361 | for range 10 { |
| 362 | t.Run("at once", func(t *testing.T) { run(t, "at once") }) |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | func TestFinishUnpaidIsNotPaid(t *testing.T) { |
| 367 | now := t0 |
| 368 | d := newStripeDouble(t) |
| 369 | rec := &recorder{subject: "42"} |
| 370 | s, db := d.service(t, &now, rec.config()) |
| 371 | seedPlans(t, s) |
| 372 | ctx := context.Background() |
| 373 | |
| 374 | d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day)}) |
| 375 | d.session("cs_open", "open", "sub_1", "", "ana@example.com") |
| 376 | f, err := s.Finish(ctx, "cs_open") |
| 377 | if err != nil { |
| 378 | t.Fatalf("Finish: %v", err) |
| 379 | } |
| 380 | if f.Paid || f.Processing { |
| 381 | t.Errorf("Finish of an open session = %+v, want neither paid nor processing", f) |
| 382 | } |
| 383 | if got := db.dump(rowsQuery); got != "" { |
| 384 | t.Errorf("an unpaid session made rows:\n%s", got) |
| 385 | } |
| 386 | |
| 387 | // Signed in: the session's client_reference_id is the subject, and |
| 388 | // the address is the customer's. |
| 389 | d.session("cs_paid", "complete", "sub_1", "7", "typed@example.com") |
| 390 | f, err = s.Finish(ctx, "cs_paid") |
| 391 | if err != nil { |
| 392 | t.Fatalf("Finish paid: %v", err) |
| 393 | } |
| 394 | if want := (abono.Finished{Email: "ana@example.com", Subject: "7", Paid: true}); f != want { |
| 395 | t.Errorf("Finish = %+v, want %+v", f, want) |
| 396 | } |
| 397 | if asked, _ := rec.take(); len(asked) != 0 { |
| 398 | t.Errorf("Holder asked %q for a signed-in reader", asked) |
| 399 | } |
| 400 | |
| 401 | if _, err := s.Finish(ctx, "cs_missing"); err == nil { |
| 402 | t.Error("Finish of a session Stripe does not have succeeded") |
| 403 | } |
| 404 | } |
| 405 | |
| 406 | // TestFinishCompleteButUnpaid: Checkout completes while a payment is still |
| 407 | // processing (a bank debit). That is not paid, and Finish records nothing: |
| 408 | // the webhook syncs once the payment lands. A session with nothing to pay |
| 409 | // (a 100% code, a trial) is paid. |
| 410 | func TestFinishCompleteButUnpaid(t *testing.T) { |
| 411 | now := t0 |
| 412 | d := newStripeDouble(t) |
| 413 | rec := &recorder{subject: "42"} |
| 414 | s, db := d.service(t, &now, rec.config()) |
| 415 | seedPlans(t, s) |
| 416 | ctx := context.Background() |
| 417 | |
| 418 | d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day)}) |
| 419 | d.session("cs_1", "complete", "sub_1", "", "ana@example.com") |
| 420 | d.paying("cs_1", "unpaid") |
| 421 | f, err := s.Finish(ctx, "cs_1") |
| 422 | if err != nil { |
| 423 | t.Fatalf("Finish: %v", err) |
| 424 | } |
| 425 | if f.Paid || !f.Processing { |
| 426 | t.Errorf("Finish of a complete but unpaid session = %+v, want processing, not paid", f) |
| 427 | } |
| 428 | if got := db.dump(rowsQuery); got != "" { |
| 429 | t.Errorf("an unpaid session made rows:\n%s", got) |
| 430 | } |
| 431 | if asked, _ := rec.take(); len(asked) != 0 { |
| 432 | t.Errorf("Holder asked %q for an unpaid session", asked) |
| 433 | } |
| 434 | |
| 435 | d.paying("cs_1", "no_payment_required") |
| 436 | f, err = s.Finish(ctx, "cs_1") |
| 437 | if err != nil { |
| 438 | t.Fatalf("Finish with nothing to pay: %v", err) |
| 439 | } |
| 440 | if !f.Paid || f.Processing || f.Subject != "42" { |
| 441 | t.Errorf("Finish with nothing to pay = %+v, want paid by 42", f) |
| 442 | } |
| 443 | if got := db.dump("SELECT subject FROM abono_subscriptions"); got != "42\n" { |
| 444 | t.Errorf("rows after a no-payment session = %q, want one for 42", got) |
| 445 | } |
| 446 | } |
| 447 | |
| 448 | // TestUnclaimedRowLearnsItsAddressLater: a reconcile records a granting |
| 449 | // subscription whose customer has no address yet, so Holder cannot be |
| 450 | // asked. Checkout's return then brings the address: Holder is asked, and |
| 451 | // if it declines the address is kept for Claim. |
| 452 | func TestUnclaimedRowLearnsItsAddressLater(t *testing.T) { |
| 453 | now := t0 |
| 454 | d := newStripeDouble(t) |
| 455 | rec := &recorder{subject: ""} |
| 456 | s, db := d.service(t, &now, rec.config()) |
| 457 | seedPlans(t, s) |
| 458 | ctx := context.Background() |
| 459 | |
| 460 | sub := d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", End: t0.Add(30 * day)}) |
| 461 | if err := s.Sync(ctx, sub, abono.Hint{}); err != nil { |
| 462 | t.Fatalf("Sync with no address: %v", err) |
| 463 | } |
| 464 | if got, want := db.dump(rowsQuery), "sub_1|cus_1||annual|price_A|active|0|\n"; got != want { |
| 465 | t.Fatalf("rows =\n%s\nwant\n%s", got, want) |
| 466 | } |
| 467 | if asked, _ := rec.take(); len(asked) != 0 { |
| 468 | t.Fatalf("Holder asked %q with no address", asked) |
| 469 | } |
| 470 | |
| 471 | // The address arrives; Holder declines: still unclaimed, address kept. |
| 472 | d.session("cs_1", "complete", "sub_1", "", "Ana@Example.com") |
| 473 | f, err := s.Finish(ctx, "cs_1") |
| 474 | if err != nil { |
| 475 | t.Fatalf("Finish: %v", err) |
| 476 | } |
| 477 | if f.Subject != "" || !f.Paid { |
| 478 | t.Errorf("Finish = %+v, want paid and unclaimed", f) |
| 479 | } |
| 480 | if got, want := db.dump(rowsQuery), "sub_1|cus_1||annual|price_A|active|0|ana@example.com\n"; got != want { |
| 481 | t.Errorf("rows =\n%s\nwant\n%s", got, want) |
| 482 | } |
| 483 | asked, changes := rec.take() |
| 484 | eq(t, "Holder asked", asked, []string{"ana@example.com"}) |
| 485 | eq(t, "OnChange", changes, nil) |
| 486 | |
| 487 | // Holder accepts on the next sync with the address: the row is theirs. |
| 488 | rec.mu.Lock() |
| 489 | rec.subject = "42" |
| 490 | rec.mu.Unlock() |
| 491 | if _, err := s.Finish(ctx, "cs_1"); err != nil { |
| 492 | t.Fatalf("Finish again: %v", err) |
| 493 | } |
| 494 | if got := db.dump("SELECT subject, email FROM abono_subscriptions"); got != "42|ana@example.com\n" { |
| 495 | t.Errorf("row = %q, want 42 with the address", got) |
| 496 | } |
| 497 | asked, changes = rec.take() |
| 498 | eq(t, "Holder asked", asked, []string{"ana@example.com"}) |
| 499 | eq(t, "OnChange", changes, []string{"42"}) |
| 500 | if !hasPlan(t, s, "42", "annual") { |
| 501 | t.Error("HasPlan(42, annual) is false") |
| 502 | } |
| 503 | } |
| 504 | |
| 505 | func TestUnclaimedThenClaim(t *testing.T) { |
| 506 | now := t0 |
| 507 | d := newStripeDouble(t) |
| 508 | rec := &recorder{subject: ""} // this app wants sign-in before payment |
| 509 | s, db := d.service(t, &now, rec.config()) |
| 510 | seedPlans(t, s) |
| 511 | ctx := context.Background() |
| 512 | |
| 513 | sub := d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day)}) |
| 514 | if err := s.Sync(ctx, sub, abono.Hint{Email: "ana@example.com"}); err != nil { |
| 515 | t.Fatalf("Sync: %v", err) |
| 516 | } |
| 517 | if got, want := db.dump(rowsQuery), "sub_1|cus_1||annual|price_A|active|0|ana@example.com\n"; got != want { |
| 518 | t.Fatalf("rows =\n%s\nwant\n%s", got, want) |
| 519 | } |
| 520 | if got := db.dump("SELECT * FROM abono_customers"); got != "" { |
| 521 | t.Errorf("an unclaimed payment mapped its customer: %q", got) |
| 522 | } |
| 523 | asked, changes := rec.take() |
| 524 | eq(t, "Holder asked", asked, []string{"ana@example.com"}) |
| 525 | eq(t, "OnChange for an unclaimed payment", changes, nil) |
| 526 | if hs, err := s.Holdings(ctx, ""); err != nil || len(hs) != 0 { |
| 527 | t.Errorf("Holdings(\"\") = %+v, %v; an unclaimed payment grants nobody", hs, err) |
| 528 | } |
| 529 | |
| 530 | if n, err := s.Claim(ctx, "6", "bob@example.com"); err != nil || n != 0 { |
| 531 | t.Errorf("Claim with another address = %d, %v; want 0", n, err) |
| 532 | } |
| 533 | if n, err := s.Claim(ctx, "5", " Ana@Example.com "); err != nil || n != 1 { |
| 534 | t.Fatalf("Claim = %d, %v; want 1", n, err) |
| 535 | } |
| 536 | if !hasPlan(t, s, "5", "annual") { |
| 537 | t.Error("HasPlan(5, annual) is false after Claim") |
| 538 | } |
| 539 | if hasPlan(t, s, "6", "annual") { |
| 540 | t.Error("the other address's claim took the plan") |
| 541 | } |
| 542 | _, changes = rec.take() |
| 543 | eq(t, "OnChange after Claim", changes, []string{"5"}) |
| 544 | if got := db.dump("SELECT stripe_customer_id, subject FROM abono_customers"); got != "cus_1|5\n" { |
| 545 | t.Errorf("customers after Claim = %q, want cus_1|5", got) |
| 546 | } |
| 547 | |
| 548 | // Claimed is claimed: a second claim of the same address attaches |
| 549 | // nothing. |
| 550 | if n, err := s.Claim(ctx, "6", "ana@example.com"); err != nil || n != 0 { |
| 551 | t.Errorf("a second Claim = %d, %v; want 0", n, err) |
| 552 | } |
| 553 | // And the next sync keeps the subject, through the customer mapping. |
| 554 | if err := s.Sync(ctx, sub, abono.Hint{}); err != nil { |
| 555 | t.Fatalf("Sync after Claim: %v", err) |
| 556 | } |
| 557 | if got := db.dump("SELECT subject FROM abono_subscriptions"); !strings.HasPrefix(got, "5") { |
| 558 | t.Errorf("subject after a later sync = %q, want 5", got) |
| 559 | } |
| 560 | } |
| 561 | |
| 562 | // TestSyncKeepsThePeriodStartForThePastDueWindow is ruling R21: a failed |
| 563 | // renewal has moved Stripe's period on, so the 30 days of past_due count |
| 564 | // from the unpaid period's start, which sync records, not from its end. |
| 565 | func TestSyncKeepsThePeriodStartForThePastDueWindow(t *testing.T) { |
| 566 | now := t0 |
| 567 | d := newStripeDouble(t) |
| 568 | rec := &recorder{} |
| 569 | s, db := d.service(t, &now, rec.config()) |
| 570 | seedPlans(t, s) |
| 571 | ctx := context.Background() |
| 572 | |
| 573 | start := t0.Add(-10 * day) // the paid year ended, the renewal failed |
| 574 | sub := d.put(t, subSpec{ID: "sub_1", Status: "past_due", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", |
| 575 | Start: start, End: start.AddDate(1, 0, 0)}) |
| 576 | if err := s.Sync(ctx, sub, abono.Hint{Subject: "ana"}); err != nil { |
| 577 | t.Fatalf("Sync: %v", err) |
| 578 | } |
| 579 | if got := db.dump(`SELECT current_period_start IS NOT NULL FROM abono_subscriptions`); got != "1\n" { |
| 580 | t.Fatalf("current_period_start stored = %q, want set", got) |
| 581 | } |
| 582 | hs, err := s.Holdings(ctx, "ana") |
| 583 | if err != nil || len(hs) != 1 || hs[0].PeriodStart == nil || !hs[0].PeriodStart.Equal(start) { |
| 584 | t.Fatalf("Holdings = %+v, %v; want the period start %v", hs, err, start) |
| 585 | } |
| 586 | if !hs[0].Grants || !hs[0].Backstop { |
| 587 | t.Errorf("10 days into the unpaid period: Grants %v, Backstop %v; want both", hs[0].Grants, hs[0].Backstop) |
| 588 | } |
| 589 | if got := abono.Describe(hs[0], now).Date; got == nil || !got.Equal(start.Add(30*day)) { |
| 590 | t.Errorf("failing date = %v, want %v", got, start.Add(30*day)) |
| 591 | } |
| 592 | |
| 593 | now = start.Add(30 * day) |
| 594 | if hasPlan(t, s, "ana", "annual") { |
| 595 | t.Error("past_due still grants 30 days after the unpaid period started, its end months away") |
| 596 | } |
| 597 | |
| 598 | // A new start (paid at last, the next period) is a change the app hears of. |
| 599 | rec.take() |
| 600 | sub = d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", |
| 601 | Start: start.AddDate(1, 0, 0), End: start.AddDate(2, 0, 0)}) |
| 602 | if err := s.Sync(ctx, sub, abono.Hint{}); err != nil { |
| 603 | t.Fatalf("Sync: %v", err) |
| 604 | } |
| 605 | if _, changes := rec.take(); len(changes) != 1 { |
| 606 | t.Errorf("OnChange after the period moved: %v, want once", changes) |
| 607 | } |
| 608 | } |
| 609 | |
| 610 | // TestConsecutiveFailedRenewalsKeepTheFirstUnpaidDay is final review C1 |
| 611 | // (ruling R32): Stripe goes on invoicing a past_due subscription, and each |
| 612 | // failed renewal moves current_period_start on. The 30 days count from the |
| 613 | // first unpaid day abono saw, so a second failed renewal does not restart |
| 614 | // them; a payment that brings the subscription back clears the day. |
| 615 | func TestConsecutiveFailedRenewalsKeepTheFirstUnpaidDay(t *testing.T) { |
| 616 | now := t0 |
| 617 | d := newStripeDouble(t) |
| 618 | rec := &recorder{} |
| 619 | s, db := d.service(t, &now, rec.config()) |
| 620 | seedPlans(t, s) |
| 621 | ctx := context.Background() |
| 622 | sync := func(when string) { |
| 623 | t.Helper() |
| 624 | now = d.Now() |
| 625 | if err := s.Sync(ctx, d.sub(t, "sub_1"), abono.Hint{Subject: "ana"}); err != nil { |
| 626 | t.Fatalf("%s: Sync: %v", when, err) |
| 627 | } |
| 628 | } |
| 629 | |
| 630 | // A monthly member, paid until 22 November. |
| 631 | paidUntil := t0.AddDate(0, 1, 0) |
| 632 | d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_M", Interval: "month", Customer: "cus_1", |
| 633 | Email: "ana@example.com", Start: t0, End: paidUntil}) |
| 634 | sync("paid") |
| 635 | |
| 636 | // The November renewal fails: 30 days from 22 November. |
| 637 | d.FailNextRenewal("sub_1") |
| 638 | d.Advance(paidUntil.Sub(d.Now())) |
| 639 | sync("the first failed renewal") |
| 640 | if !hasPlan(t, s, "ana", "monthly") { |
| 641 | t.Fatal("past_due on its first unpaid day does not grant") |
| 642 | } |
| 643 | |
| 644 | // The December renewal fails too. Stripe's period now starts on 22 |
| 645 | // December, which is also the day the first 30 days ran out. |
| 646 | d.Advance(paidUntil.AddDate(0, 1, 0).Sub(d.Now())) |
| 647 | sync("the second failed renewal") |
| 648 | if sub := d.sub(t, "sub_1"); sub.Status != "past_due" || !sub.PeriodStart().Equal(paidUntil.AddDate(0, 1, 0)) { |
| 649 | t.Fatalf("the double did not invoice again: %s from %v", sub.Status, sub.PeriodStart()) |
| 650 | } |
| 651 | if hasPlan(t, s, "ana", "monthly") { |
| 652 | t.Error("a second failed renewal restarted the 30 days: past_due still grants a month after the first unpaid day") |
| 653 | } |
| 654 | hs, err := s.Holdings(ctx, "ana") |
| 655 | if err != nil || len(hs) != 1 { |
| 656 | t.Fatalf("Holdings = %+v, %v", hs, err) |
| 657 | } |
| 658 | if got := abono.Describe(hs[0], paidUntil.Add(29*day)).Date; got == nil || !got.Equal(paidUntil.Add(30*day)) { |
| 659 | t.Errorf("the failing date = %v, want 30 days from the first unpaid day, %v", got, paidUntil.Add(30*day)) |
| 660 | } |
| 661 | if got, want := db.dump(`SELECT unpaid_since FROM abono_subscriptions`), paidUntil.Format("2006-01-02 15:04:05")+" +0000 UTC\n"; got != want { |
| 662 | t.Errorf("unpaid_since = %q, want %q", got, want) |
| 663 | } |
| 664 | |
| 665 | // Paid at last: active again, and the unpaid day is forgotten, so a |
| 666 | // later failure starts its own 30 days. |
| 667 | back := paidUntil.AddDate(0, 1, 0) |
| 668 | d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_M", Interval: "month", Customer: "cus_1", |
| 669 | Email: "ana@example.com", Start: back, End: back.AddDate(0, 1, 0)}) |
| 670 | sync("paid again") |
| 671 | if !hasPlan(t, s, "ana", "monthly") { |
| 672 | t.Error("paid again does not grant") |
| 673 | } |
| 674 | if got := db.dump(`SELECT unpaid_since IS NULL FROM abono_subscriptions`); got != "1\n" { |
| 675 | t.Errorf("unpaid_since after paying = %q, want cleared", got) |
| 676 | } |
| 677 | d.FailNextRenewal("sub_1") |
| 678 | d.Advance(back.AddDate(0, 1, 0).Sub(d.Now())) |
| 679 | sync("a later failed renewal") |
| 680 | if !hasPlan(t, s, "ana", "monthly") { |
| 681 | t.Error("a new failure after paying does not get its own 30 days") |
| 682 | } |
| 683 | } |
| 684 | |
| 685 | // TestAnOlderSnapshotNeverOverwritesANewerOne is final review I2 (ruling |
| 686 | // R35). Every entry point fetches the subscription from Stripe and then |
| 687 | // writes it. When Stripe changes it between two such fetches, the one |
| 688 | // that fetched first must not write last: A fetches active, Stripe cancels |
| 689 | // the subscription, B fetches canceled; A then waits for B (as long as B |
| 690 | // can get on) before writing. Unserialised, B writes canceled and A |
| 691 | // overwrites it with active, restoring access. One lock per subscription, |
| 692 | // held from the fetch to the commit, makes B wait for A instead, so the |
| 693 | // row ends canceled whichever entry point is A and which is B. |
| 694 | func TestAnOlderSnapshotNeverOverwritesANewerOne(t *testing.T) { |
| 695 | type entry struct { |
| 696 | name string |
| 697 | run func(t *testing.T, s *abono.Service, n int) error |
| 698 | } |
| 699 | finish := entry{"Finish", func(t *testing.T, s *abono.Service, _ int) error { |
| 700 | _, err := s.Finish(context.Background(), "cs_1") |
| 701 | return err |
| 702 | }} |
| 703 | webhook := entry{"the webhook", func(t *testing.T, s *abono.Service, n int) error { |
| 704 | w := deliver(t, s, event(fmt.Sprintf("evt_%d", n), "customer.subscription.updated", map[string]any{"id": "sub_1"})) |
| 705 | if w.Code != http.StatusOK { |
| 706 | return fmt.Errorf("webhook: %d %s", w.Code, w.Body.String()) |
| 707 | } |
| 708 | return nil |
| 709 | }} |
| 710 | reconcile := entry{"Reconcile", func(t *testing.T, s *abono.Service, _ int) error { |
| 711 | _, err := s.Reconcile(context.Background()) |
| 712 | return err |
| 713 | }} |
| 714 | adopt := entry{"Adopt", func(t *testing.T, s *abono.Service, _ int) error { |
| 715 | return s.Adopt(context.Background(), "sub_1", "", "annual") |
| 716 | }} |
| 717 | for _, pair := range [][2]entry{ |
| 718 | {finish, webhook}, {webhook, finish}, {reconcile, webhook}, {webhook, reconcile}, {adopt, webhook}, {webhook, adopt}, |
| 719 | } { |
| 720 | a, b := pair[0], pair[1] |
| 721 | t.Run(a.name+" fetches first, then "+b.name, func(t *testing.T) { |
| 722 | now := t0 |
| 723 | d := newStripeDouble(t) |
| 724 | rec := &recorder{subject: "42"} |
| 725 | s, db := d.service(t, &now, rec.config()) |
| 726 | seedPlans(t, s) |
| 727 | active := subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day)} |
| 728 | // Recorded already, so every entry point's first read of sub_1 |
| 729 | // from Stripe is the one it writes from. |
| 730 | if err := s.Sync(context.Background(), d.put(t, active), abono.Hint{}); err != nil { |
| 731 | t.Fatal(err) |
| 732 | } |
| 733 | d.session("cs_1", "complete", "sub_1", "", "ana@example.com") |
| 734 | |
| 735 | var bErr error |
| 736 | bDone := make(chan struct{}) |
| 737 | var first atomic.Bool |
| 738 | d.OnSubscriptionFetch(func() { |
| 739 | // Only A's fetch, the first; B's must not wait on it. |
| 740 | if !first.CompareAndSwap(false, true) { |
| 741 | return |
| 742 | } |
| 743 | // A has its snapshot (active). Stripe cancels now, and B |
| 744 | // starts; A carries on once B is done, or once it is clear |
| 745 | // B is waiting for A. |
| 746 | canceled := active |
| 747 | canceled.Status = "canceled" |
| 748 | d.put(t, canceled) |
| 749 | go func() { defer close(bDone); bErr = b.run(t, s, 2) }() |
| 750 | select { |
| 751 | case <-bDone: |
| 752 | case <-time.After(300 * time.Millisecond): |
| 753 | } |
| 754 | }) |
| 755 | if err := a.run(t, s, 1); err != nil { |
| 756 | t.Fatalf("%s: %v", a.name, err) |
| 757 | } |
| 758 | select { |
| 759 | case <-bDone: |
| 760 | case <-time.After(10 * time.Second): |
| 761 | t.Fatalf("%s never fetched sub_1 itself, so nothing made B run: its snapshot came from somewhere else", a.name) |
| 762 | } |
| 763 | if bErr != nil { |
| 764 | t.Fatalf("%s: %v", b.name, bErr) |
| 765 | } |
| 766 | if got := db.dump(`SELECT status FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_1'`); got != "canceled\n" { |
| 767 | t.Errorf("status = %q, want canceled: the older snapshot was written last", got) |
| 768 | } |
| 769 | if hasPlan(t, s, "42", "annual") { |
| 770 | t.Error("a canceled subscription still grants") |
| 771 | } |
| 772 | }) |
| 773 | } |
| 774 | } |
| 775 | |