rastrillo / abono Public

Clone
git clone https://amadan.net/rastrillo/abono

Plain git — no account needed to clone.

Download

Download this file

1package abono_test
2
3import (
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
17const 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).
22func 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
32func 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
41func 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.
56func 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
165func 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.
197func 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.
248func 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.
297func 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
366func 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.
410func 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.
452func 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
505func 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.
565func 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.
615func 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.
694func 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