package abono_test import ( "context" "fmt" "net/http" "reflect" "strings" "sync" "sync/atomic" "testing" "time" "amadan.net/rastrillo/abono" ) const rowsQuery = `SELECT stripe_subscription_id, stripe_customer_id, subject, plan, stripe_price_id, status, cancel_at_period_end, email FROM abono_subscriptions ORDER BY stripe_subscription_id` // seedPlans sells annual (price_A, with legacy price_old) and monthly // (price_M). func seedPlans(t *testing.T, s *abono.Service) { t.Helper() if err := s.SeedPlans(context.Background(), []abono.PlanSpec{ {Plan: "annual", Sold: "price_A", Legacy: []string{"price_old"}}, {Plan: "monthly", Sold: "price_M"}, }); err != nil { t.Fatalf("SeedPlans: %v", err) } } func hasPlan(t *testing.T, s *abono.Service, subject, plan string) bool { t.Helper() ok, err := s.HasPlan(context.Background(), subject, plan) if err != nil { t.Fatalf("HasPlan(%q, %q): %v", subject, plan, err) } return ok } func eq(t *testing.T, what string, got, want []string) { t.Helper() if len(got) == 0 && len(want) == 0 { return } if !reflect.DeepEqual(got, want) { t.Errorf("%s = %q, want %q", what, got, want) } } // TestSyncCreatesOnlyWithAHint is the creation rule (spec 4.1, rulings R16 // and R16a): with no hint, a subscription that grants is recorded, so a // reconcile recovers a webhook Stripe gave up on. On a price no offer has // it is recorded unrecognised, for the owner to adopt, and Holder is never // asked. An ended subscription first seen now is never recorded. func TestSyncCreatesOnlyWithAHint(t *testing.T) { now := t0 d := newStripeDouble(t) rec := &recorder{subject: "42"} s, db := d.service(t, &now, rec.config()) seedPlans(t, s) ctx := context.Background() // Ended, on a known price: nothing, with or without a hint. ended := d.put(t, subSpec{ID: "sub_0", Status: "canceled", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(-day)}) for _, hint := range []abono.Hint{{}, {Email: "ana@example.com"}} { if err := s.Sync(ctx, ended, hint); err != nil { t.Fatalf("Sync of an ended subscription, hint %+v: %v", hint, err) } } if got := db.dump(rowsQuery); got != "" { t.Fatalf("an ended subscription first seen now was recorded:\n%s", got) } // Granting, on a price no offer has, no hint: recorded unrecognised and // tied to nobody, without asking Holder or telling anyone. 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)}) if err := s.Sync(ctx, other, abono.Hint{}); err != nil { t.Fatalf("Sync of an unknown price with no hint: %v", err) } if got, want := db.dump(rowsQuery), "sub_z|cus_9|||price_Z|active|0|zed@example.com\n"; got != want { t.Fatalf("rows =\n%s\nwant\n%s", got, want) } asked, changes := rec.take() eq(t, "Holder asked", asked, nil) eq(t, "OnChange", changes, nil) if un, err := s.Unrecognised(ctx); err != nil || len(un) != 1 || un[0].SubscriptionID != "sub_z" { t.Errorf("Unrecognised = %+v, %v; want sub_z", un, err) } // Ended, on a price no offer has, no hint: nothing. gone := d.put(t, subSpec{ID: "sub_y", Status: "canceled", Price: "price_Z", Customer: "cus_9", Email: "zed@example.com", End: t0.Add(-day)}) if err := s.Sync(ctx, gone, abono.Hint{}); err != nil { t.Fatalf("Sync of an ended unknown price: %v", err) } if got := db.dump("SELECT * FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_y'"); got != "" { t.Errorf("an ended unknown-price subscription was recorded: %q", got) } // Granting, on a known price, no hint: recovered, its holder found by // the customer's address. 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)}) if err := s.Sync(ctx, sub, abono.Hint{}); err != nil { t.Fatalf("Sync with no hint: %v", err) } if got, want := db.dump(rowsQuery), "sub_1|cus_1|42|annual|price_A|active|0|ana@example.com\n"+ "sub_z|cus_9|||price_Z|active|0|zed@example.com\n"; got != want { t.Fatalf("rows =\n%s\nwant\n%s", got, want) } if got := db.dump("SELECT stripe_customer_id, subject FROM abono_customers"); got != "cus_1|42\n" { t.Errorf("customers = %q, want the Holder's subject recorded", got) } asked, changes = rec.take() eq(t, "Holder asked", asked, []string{"ana@example.com"}) eq(t, "OnChange", changes, []string{"42"}) // A legacy price is a known price too, and a mapped customer decides // before Holder is asked. 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)}) if err := s.Sync(ctx, legacy, abono.Hint{}); err != nil { t.Fatalf("Sync of a legacy price: %v", err) } if got := db.dump("SELECT subject, plan FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_L'"); got != "42|annual\n" { t.Errorf("legacy row = %q, want 42|annual", got) } asked, changes = rec.take() eq(t, "Holder asked for a mapped customer", asked, nil) eq(t, "OnChange for the legacy row", changes, []string{"42"}) // An unknown price of a mapped customer is tied to them, still // unrecognised, and tells nobody. 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)}) if err := s.Sync(ctx, mapped, abono.Hint{}); err != nil { t.Fatalf("Sync of a mapped customer's unknown price: %v", err) } if got := db.dump("SELECT subject, plan FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_z2'"); got != "42|\n" { t.Errorf("sub_z2 = %q, want 42 and no plan", got) } asked, changes = rec.take() eq(t, "Holder asked for an unknown price", asked, nil) eq(t, "OnChange for an unknown price", changes, nil) if !hasPlan(t, s, "42", "annual") { t.Error("HasPlan(42, annual) is false after the recovered sync") } // The same subscription again changes nothing and tells nobody. if err := s.Sync(ctx, sub, abono.Hint{}); err != nil { t.Fatalf("Sync again: %v", err) } asked, changes = rec.take() eq(t, "Holder asked on a repeat", asked, nil) eq(t, "OnChange on a repeat", changes, nil) // An existing row follows Stripe with no hint. 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}) if err := s.Sync(ctx, sub, abono.Hint{}); err != nil { t.Fatalf("Sync of a cancelling subscription: %v", err) } if got := db.dump("SELECT cancel_at_period_end FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_1'"); got != "1\n" { t.Errorf("cancel_at_period_end = %q, want 1", got) } _, changes = rec.take() eq(t, "OnChange after cancelling", changes, []string{"42"}) } func TestSyncUnknownPriceIsUnrecognised(t *testing.T) { now := t0 d := newStripeDouble(t) rec := &recorder{subject: "42"} s, db := d.service(t, &now, rec.config()) seedPlans(t, s) ctx := context.Background() 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)}) if err := s.Sync(ctx, sub, abono.Hint{Email: "ana@example.com"}); err != nil { t.Fatalf("Sync: %v", err) } if got, want := db.dump(rowsQuery), "sub_1|cus_1|||price_Z|active|0|ana@example.com\n"; got != want { t.Fatalf("rows =\n%s\nwant\n%s", got, want) } asked, changes := rec.take() eq(t, "Holder asked for an unrecognised price", asked, nil) eq(t, "OnChange for an unrecognised price", changes, nil) un, err := s.Unrecognised(ctx) if err != nil { t.Fatalf("Unrecognised: %v", err) } want := []abono.Unrecognised{{SubscriptionID: "sub_1", CustomerID: "cus_1", PriceID: "price_Z", Status: "active"}} if !reflect.DeepEqual(un, want) { t.Errorf("Unrecognised = %+v, want %+v", un, want) } } // TestPortalPlanSwitchMovesThePlan is Review Focus 2: a reader switching // plan in the Customer Portal keeps the same subscription id on a new // price. func TestPortalPlanSwitchMovesThePlan(t *testing.T) { now := t0 d := newStripeDouble(t) rec := &recorder{} s, db := d.service(t, &now, rec.config()) seedPlans(t, s) ctx := context.Background() spec := subSpec{ID: "sub_1", Status: "active", Price: "price_M", Interval: "month", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(20 * day)} if err := s.Sync(ctx, d.put(t, spec), abono.Hint{Subject: "5"}); err != nil { t.Fatalf("Sync monthly: %v", err) } if !hasPlan(t, s, "5", "monthly") { t.Fatal("HasPlan(5, monthly) is false") } rec.take() spec.Price, spec.Interval = "price_A", "year" if err := s.Sync(ctx, d.put(t, spec), abono.Hint{}); err != nil { t.Fatalf("Sync annual: %v", err) } if got := db.dump("SELECT plan, stripe_price_id FROM abono_subscriptions"); got != "annual|price_A\n" { t.Errorf("after the switch the row is %q, want annual|price_A", got) } if !hasPlan(t, s, "5", "annual") || hasPlan(t, s, "5", "monthly") { t.Error("after the switch, want annual and not monthly") } _, changes := rec.take() eq(t, "OnChange after the switch", changes, []string{"5"}) spec.Price = "price_Z" if err := s.Sync(ctx, d.put(t, spec), abono.Hint{}); err != nil { t.Fatalf("Sync onto an unknown price: %v", err) } if hasPlan(t, s, "5", "annual") { t.Error("an unknown price still grants annual") } if got := db.dump("SELECT subject, plan, stripe_price_id FROM abono_subscriptions"); got != "5||price_Z\n" { t.Errorf("row = %q, want 5||price_Z", got) } un, err := s.Unrecognised(ctx) if err != nil || len(un) != 1 || un[0].Subject != "5" { t.Errorf("Unrecognised = %+v, %v; want sub_1 for subject 5", un, err) } // Losing the plan is a change to subject 5's holdings, so the app // hears of it. _, changes = rec.take() eq(t, "OnChange after moving to an unknown price", changes, []string{"5"}) } // TestHintSubjectBeatsAddress is Review Focus 3. func TestHintSubjectBeatsAddress(t *testing.T) { now := t0 d := newStripeDouble(t) rec := &recorder{subject: "9"} s, db := d.service(t, &now, rec.config()) seedPlans(t, s) ctx := context.Background() 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)}) if err := s.Sync(ctx, sub, abono.Hint{Subject: "7", Email: "nine@example.com"}); err != nil { t.Fatalf("Sync: %v", err) } if got := db.dump("SELECT subject FROM abono_subscriptions"); got != "7\n" { t.Errorf("subject = %q, want 7", got) } asked, changes := rec.take() eq(t, "Holder asked", asked, nil) eq(t, "OnChange", changes, []string{"7"}) if !hasPlan(t, s, "7", "annual") || hasPlan(t, s, "9", "annual") { t.Error("want 7 to hold annual and 9 not") } // A row first made from the address is corrected by a later hint, and // both subjects hear of it. 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)}) if err := s.Sync(ctx, other, abono.Hint{Email: "nine@example.com"}); err != nil { t.Fatalf("Sync sub_2 by address: %v", err) } if got := db.dump("SELECT subject FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_2'"); got != "9\n" { t.Fatalf("sub_2 subject = %q, want 9 from Holder", got) } rec.take() if err := s.Sync(ctx, other, abono.Hint{Subject: "7"}); err != nil { t.Fatalf("Sync sub_2 with a subject: %v", err) } if got := db.dump("SELECT subject FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_2'"); got != "7\n" { t.Errorf("sub_2 subject = %q, want 7 after the hint", got) } asked, changes = rec.take() eq(t, "Holder asked on correction", asked, nil) eq(t, "OnChange on correction", changes, []string{"9", "7"}) if hasPlan(t, s, "9", "monthly") || !hasPlan(t, s, "7", "monthly") { t.Error("after the correction, want 7 to hold monthly and 9 not") } } // TestFinishAndWebhookRace is Review Focus 1: Checkout's return and the // webhook sync the same new subscription, in either order or at once, // and make one row and at most one person. func TestFinishAndWebhookRace(t *testing.T) { run := func(t *testing.T, order string) { now := t0 d := newStripeDouble(t) rec := &recorder{subject: "42"} s, db := d.service(t, &now, rec.config()) seedPlans(t, s) ctx := context.Background() 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)}) d.session("cs_1", "complete", "sub_1", "", "ana@example.com") hint := abono.Hint{Email: "ana@example.com"} finish := func() error { f, err := s.Finish(ctx, "cs_1") if err == nil && (!f.Paid || f.Subject != "42") { t.Errorf("Finish = %+v, want paid by 42", f) } return err } webhook := func() error { return s.Sync(ctx, sub, hint) } var errs [2]error switch order { case "finish first": errs[0], errs[1] = finish(), webhook() case "webhook first": errs[1], errs[0] = webhook(), finish() case "at once": // The webhook needs no round trip to Stripe and would always // finish before Finish has read anything. Holding Holder until // Finish has fetched the subscription puts Finish's read in the // middle of the webhook's write, which is the race. fetched := make(chan struct{}) var once sync.Once d.OnSubscriptionFetch(func() { once.Do(func() { close(fetched) }) }) rec.pause = func() { select { case <-fetched: case <-time.After(2 * time.Second): } time.Sleep(20 * time.Millisecond) } var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done(); errs[0] = finish() }() go func() { defer wg.Done(); errs[1] = webhook() }() wg.Wait() } for _, err := range errs { if err != nil { t.Fatalf("%s: %v", order, err) } } if got, want := db.dump(rowsQuery), "sub_1|cus_1|42|annual|price_A|active|0|ana@example.com\n"; got != want { t.Errorf("%s: rows =\n%s\nwant\n%s", order, got, want) } if asked, _ := rec.take(); len(asked) > 1 { t.Errorf("%s: Holder called %d times, want at most once", order, len(asked)) } } for _, order := range []string{"finish first", "webhook first"} { t.Run(order, func(t *testing.T) { run(t, order) }) } for range 10 { t.Run("at once", func(t *testing.T) { run(t, "at once") }) } } func TestFinishUnpaidIsNotPaid(t *testing.T) { now := t0 d := newStripeDouble(t) rec := &recorder{subject: "42"} s, db := d.service(t, &now, rec.config()) seedPlans(t, s) ctx := context.Background() d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day)}) d.session("cs_open", "open", "sub_1", "", "ana@example.com") f, err := s.Finish(ctx, "cs_open") if err != nil { t.Fatalf("Finish: %v", err) } if f.Paid || f.Processing { t.Errorf("Finish of an open session = %+v, want neither paid nor processing", f) } if got := db.dump(rowsQuery); got != "" { t.Errorf("an unpaid session made rows:\n%s", got) } // Signed in: the session's client_reference_id is the subject, and // the address is the customer's. d.session("cs_paid", "complete", "sub_1", "7", "typed@example.com") f, err = s.Finish(ctx, "cs_paid") if err != nil { t.Fatalf("Finish paid: %v", err) } if want := (abono.Finished{Email: "ana@example.com", Subject: "7", Paid: true}); f != want { t.Errorf("Finish = %+v, want %+v", f, want) } if asked, _ := rec.take(); len(asked) != 0 { t.Errorf("Holder asked %q for a signed-in reader", asked) } if _, err := s.Finish(ctx, "cs_missing"); err == nil { t.Error("Finish of a session Stripe does not have succeeded") } } // TestFinishCompleteButUnpaid: Checkout completes while a payment is still // processing (a bank debit). That is not paid, and Finish records nothing: // the webhook syncs once the payment lands. A session with nothing to pay // (a 100% code, a trial) is paid. func TestFinishCompleteButUnpaid(t *testing.T) { now := t0 d := newStripeDouble(t) rec := &recorder{subject: "42"} s, db := d.service(t, &now, rec.config()) seedPlans(t, s) ctx := context.Background() d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day)}) d.session("cs_1", "complete", "sub_1", "", "ana@example.com") d.paying("cs_1", "unpaid") f, err := s.Finish(ctx, "cs_1") if err != nil { t.Fatalf("Finish: %v", err) } if f.Paid || !f.Processing { t.Errorf("Finish of a complete but unpaid session = %+v, want processing, not paid", f) } if got := db.dump(rowsQuery); got != "" { t.Errorf("an unpaid session made rows:\n%s", got) } if asked, _ := rec.take(); len(asked) != 0 { t.Errorf("Holder asked %q for an unpaid session", asked) } d.paying("cs_1", "no_payment_required") f, err = s.Finish(ctx, "cs_1") if err != nil { t.Fatalf("Finish with nothing to pay: %v", err) } if !f.Paid || f.Processing || f.Subject != "42" { t.Errorf("Finish with nothing to pay = %+v, want paid by 42", f) } if got := db.dump("SELECT subject FROM abono_subscriptions"); got != "42\n" { t.Errorf("rows after a no-payment session = %q, want one for 42", got) } } // TestUnclaimedRowLearnsItsAddressLater: a reconcile records a granting // subscription whose customer has no address yet, so Holder cannot be // asked. Checkout's return then brings the address: Holder is asked, and // if it declines the address is kept for Claim. func TestUnclaimedRowLearnsItsAddressLater(t *testing.T) { now := t0 d := newStripeDouble(t) rec := &recorder{subject: ""} s, db := d.service(t, &now, rec.config()) seedPlans(t, s) ctx := context.Background() sub := d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", End: t0.Add(30 * day)}) if err := s.Sync(ctx, sub, abono.Hint{}); err != nil { t.Fatalf("Sync with no address: %v", err) } if got, want := db.dump(rowsQuery), "sub_1|cus_1||annual|price_A|active|0|\n"; got != want { t.Fatalf("rows =\n%s\nwant\n%s", got, want) } if asked, _ := rec.take(); len(asked) != 0 { t.Fatalf("Holder asked %q with no address", asked) } // The address arrives; Holder declines: still unclaimed, address kept. d.session("cs_1", "complete", "sub_1", "", "Ana@Example.com") f, err := s.Finish(ctx, "cs_1") if err != nil { t.Fatalf("Finish: %v", err) } if f.Subject != "" || !f.Paid { t.Errorf("Finish = %+v, want paid and unclaimed", f) } if got, want := db.dump(rowsQuery), "sub_1|cus_1||annual|price_A|active|0|ana@example.com\n"; got != want { t.Errorf("rows =\n%s\nwant\n%s", got, want) } asked, changes := rec.take() eq(t, "Holder asked", asked, []string{"ana@example.com"}) eq(t, "OnChange", changes, nil) // Holder accepts on the next sync with the address: the row is theirs. rec.mu.Lock() rec.subject = "42" rec.mu.Unlock() if _, err := s.Finish(ctx, "cs_1"); err != nil { t.Fatalf("Finish again: %v", err) } if got := db.dump("SELECT subject, email FROM abono_subscriptions"); got != "42|ana@example.com\n" { t.Errorf("row = %q, want 42 with the address", got) } asked, changes = rec.take() eq(t, "Holder asked", asked, []string{"ana@example.com"}) eq(t, "OnChange", changes, []string{"42"}) if !hasPlan(t, s, "42", "annual") { t.Error("HasPlan(42, annual) is false") } } func TestUnclaimedThenClaim(t *testing.T) { now := t0 d := newStripeDouble(t) rec := &recorder{subject: ""} // this app wants sign-in before payment s, db := d.service(t, &now, rec.config()) seedPlans(t, s) ctx := context.Background() 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)}) if err := s.Sync(ctx, sub, abono.Hint{Email: "ana@example.com"}); err != nil { t.Fatalf("Sync: %v", err) } if got, want := db.dump(rowsQuery), "sub_1|cus_1||annual|price_A|active|0|ana@example.com\n"; got != want { t.Fatalf("rows =\n%s\nwant\n%s", got, want) } if got := db.dump("SELECT * FROM abono_customers"); got != "" { t.Errorf("an unclaimed payment mapped its customer: %q", got) } asked, changes := rec.take() eq(t, "Holder asked", asked, []string{"ana@example.com"}) eq(t, "OnChange for an unclaimed payment", changes, nil) if hs, err := s.Holdings(ctx, ""); err != nil || len(hs) != 0 { t.Errorf("Holdings(\"\") = %+v, %v; an unclaimed payment grants nobody", hs, err) } if n, err := s.Claim(ctx, "6", "bob@example.com"); err != nil || n != 0 { t.Errorf("Claim with another address = %d, %v; want 0", n, err) } if n, err := s.Claim(ctx, "5", " Ana@Example.com "); err != nil || n != 1 { t.Fatalf("Claim = %d, %v; want 1", n, err) } if !hasPlan(t, s, "5", "annual") { t.Error("HasPlan(5, annual) is false after Claim") } if hasPlan(t, s, "6", "annual") { t.Error("the other address's claim took the plan") } _, changes = rec.take() eq(t, "OnChange after Claim", changes, []string{"5"}) if got := db.dump("SELECT stripe_customer_id, subject FROM abono_customers"); got != "cus_1|5\n" { t.Errorf("customers after Claim = %q, want cus_1|5", got) } // Claimed is claimed: a second claim of the same address attaches // nothing. if n, err := s.Claim(ctx, "6", "ana@example.com"); err != nil || n != 0 { t.Errorf("a second Claim = %d, %v; want 0", n, err) } // And the next sync keeps the subject, through the customer mapping. if err := s.Sync(ctx, sub, abono.Hint{}); err != nil { t.Fatalf("Sync after Claim: %v", err) } if got := db.dump("SELECT subject FROM abono_subscriptions"); !strings.HasPrefix(got, "5") { t.Errorf("subject after a later sync = %q, want 5", got) } } // TestSyncKeepsThePeriodStartForThePastDueWindow is ruling R21: a failed // renewal has moved Stripe's period on, so the 30 days of past_due count // from the unpaid period's start, which sync records, not from its end. func TestSyncKeepsThePeriodStartForThePastDueWindow(t *testing.T) { now := t0 d := newStripeDouble(t) rec := &recorder{} s, db := d.service(t, &now, rec.config()) seedPlans(t, s) ctx := context.Background() start := t0.Add(-10 * day) // the paid year ended, the renewal failed sub := d.put(t, subSpec{ID: "sub_1", Status: "past_due", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", Start: start, End: start.AddDate(1, 0, 0)}) if err := s.Sync(ctx, sub, abono.Hint{Subject: "ana"}); err != nil { t.Fatalf("Sync: %v", err) } if got := db.dump(`SELECT current_period_start IS NOT NULL FROM abono_subscriptions`); got != "1\n" { t.Fatalf("current_period_start stored = %q, want set", got) } hs, err := s.Holdings(ctx, "ana") if err != nil || len(hs) != 1 || hs[0].PeriodStart == nil || !hs[0].PeriodStart.Equal(start) { t.Fatalf("Holdings = %+v, %v; want the period start %v", hs, err, start) } if !hs[0].Grants || !hs[0].Backstop { t.Errorf("10 days into the unpaid period: Grants %v, Backstop %v; want both", hs[0].Grants, hs[0].Backstop) } if got := abono.Describe(hs[0], now).Date; got == nil || !got.Equal(start.Add(30*day)) { t.Errorf("failing date = %v, want %v", got, start.Add(30*day)) } now = start.Add(30 * day) if hasPlan(t, s, "ana", "annual") { t.Error("past_due still grants 30 days after the unpaid period started, its end months away") } // A new start (paid at last, the next period) is a change the app hears of. rec.take() sub = d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", Start: start.AddDate(1, 0, 0), End: start.AddDate(2, 0, 0)}) if err := s.Sync(ctx, sub, abono.Hint{}); err != nil { t.Fatalf("Sync: %v", err) } if _, changes := rec.take(); len(changes) != 1 { t.Errorf("OnChange after the period moved: %v, want once", changes) } } // TestConsecutiveFailedRenewalsKeepTheFirstUnpaidDay is final review C1 // (ruling R32): Stripe goes on invoicing a past_due subscription, and each // failed renewal moves current_period_start on. The 30 days count from the // first unpaid day abono saw, so a second failed renewal does not restart // them; a payment that brings the subscription back clears the day. func TestConsecutiveFailedRenewalsKeepTheFirstUnpaidDay(t *testing.T) { now := t0 d := newStripeDouble(t) rec := &recorder{} s, db := d.service(t, &now, rec.config()) seedPlans(t, s) ctx := context.Background() sync := func(when string) { t.Helper() now = d.Now() if err := s.Sync(ctx, d.sub(t, "sub_1"), abono.Hint{Subject: "ana"}); err != nil { t.Fatalf("%s: Sync: %v", when, err) } } // A monthly member, paid until 22 November. paidUntil := t0.AddDate(0, 1, 0) d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_M", Interval: "month", Customer: "cus_1", Email: "ana@example.com", Start: t0, End: paidUntil}) sync("paid") // The November renewal fails: 30 days from 22 November. d.FailNextRenewal("sub_1") d.Advance(paidUntil.Sub(d.Now())) sync("the first failed renewal") if !hasPlan(t, s, "ana", "monthly") { t.Fatal("past_due on its first unpaid day does not grant") } // The December renewal fails too. Stripe's period now starts on 22 // December, which is also the day the first 30 days ran out. d.Advance(paidUntil.AddDate(0, 1, 0).Sub(d.Now())) sync("the second failed renewal") if sub := d.sub(t, "sub_1"); sub.Status != "past_due" || !sub.PeriodStart().Equal(paidUntil.AddDate(0, 1, 0)) { t.Fatalf("the double did not invoice again: %s from %v", sub.Status, sub.PeriodStart()) } if hasPlan(t, s, "ana", "monthly") { t.Error("a second failed renewal restarted the 30 days: past_due still grants a month after the first unpaid day") } hs, err := s.Holdings(ctx, "ana") if err != nil || len(hs) != 1 { t.Fatalf("Holdings = %+v, %v", hs, err) } if got := abono.Describe(hs[0], paidUntil.Add(29*day)).Date; got == nil || !got.Equal(paidUntil.Add(30*day)) { t.Errorf("the failing date = %v, want 30 days from the first unpaid day, %v", got, paidUntil.Add(30*day)) } if got, want := db.dump(`SELECT unpaid_since FROM abono_subscriptions`), paidUntil.Format("2006-01-02 15:04:05")+" +0000 UTC\n"; got != want { t.Errorf("unpaid_since = %q, want %q", got, want) } // Paid at last: active again, and the unpaid day is forgotten, so a // later failure starts its own 30 days. back := paidUntil.AddDate(0, 1, 0) d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_M", Interval: "month", Customer: "cus_1", Email: "ana@example.com", Start: back, End: back.AddDate(0, 1, 0)}) sync("paid again") if !hasPlan(t, s, "ana", "monthly") { t.Error("paid again does not grant") } if got := db.dump(`SELECT unpaid_since IS NULL FROM abono_subscriptions`); got != "1\n" { t.Errorf("unpaid_since after paying = %q, want cleared", got) } d.FailNextRenewal("sub_1") d.Advance(back.AddDate(0, 1, 0).Sub(d.Now())) sync("a later failed renewal") if !hasPlan(t, s, "ana", "monthly") { t.Error("a new failure after paying does not get its own 30 days") } } // TestAnOlderSnapshotNeverOverwritesANewerOne is final review I2 (ruling // R35). Every entry point fetches the subscription from Stripe and then // writes it. When Stripe changes it between two such fetches, the one // that fetched first must not write last: A fetches active, Stripe cancels // the subscription, B fetches canceled; A then waits for B (as long as B // can get on) before writing. Unserialised, B writes canceled and A // overwrites it with active, restoring access. One lock per subscription, // held from the fetch to the commit, makes B wait for A instead, so the // row ends canceled whichever entry point is A and which is B. func TestAnOlderSnapshotNeverOverwritesANewerOne(t *testing.T) { type entry struct { name string run func(t *testing.T, s *abono.Service, n int) error } finish := entry{"Finish", func(t *testing.T, s *abono.Service, _ int) error { _, err := s.Finish(context.Background(), "cs_1") return err }} webhook := entry{"the webhook", func(t *testing.T, s *abono.Service, n int) error { w := deliver(t, s, event(fmt.Sprintf("evt_%d", n), "customer.subscription.updated", map[string]any{"id": "sub_1"})) if w.Code != http.StatusOK { return fmt.Errorf("webhook: %d %s", w.Code, w.Body.String()) } return nil }} reconcile := entry{"Reconcile", func(t *testing.T, s *abono.Service, _ int) error { _, err := s.Reconcile(context.Background()) return err }} adopt := entry{"Adopt", func(t *testing.T, s *abono.Service, _ int) error { return s.Adopt(context.Background(), "sub_1", "", "annual") }} for _, pair := range [][2]entry{ {finish, webhook}, {webhook, finish}, {reconcile, webhook}, {webhook, reconcile}, {adopt, webhook}, {webhook, adopt}, } { a, b := pair[0], pair[1] t.Run(a.name+" fetches first, then "+b.name, func(t *testing.T) { now := t0 d := newStripeDouble(t) rec := &recorder{subject: "42"} s, db := d.service(t, &now, rec.config()) seedPlans(t, s) active := subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day)} // Recorded already, so every entry point's first read of sub_1 // from Stripe is the one it writes from. if err := s.Sync(context.Background(), d.put(t, active), abono.Hint{}); err != nil { t.Fatal(err) } d.session("cs_1", "complete", "sub_1", "", "ana@example.com") var bErr error bDone := make(chan struct{}) var first atomic.Bool d.OnSubscriptionFetch(func() { // Only A's fetch, the first; B's must not wait on it. if !first.CompareAndSwap(false, true) { return } // A has its snapshot (active). Stripe cancels now, and B // starts; A carries on once B is done, or once it is clear // B is waiting for A. canceled := active canceled.Status = "canceled" d.put(t, canceled) go func() { defer close(bDone); bErr = b.run(t, s, 2) }() select { case <-bDone: case <-time.After(300 * time.Millisecond): } }) if err := a.run(t, s, 1); err != nil { t.Fatalf("%s: %v", a.name, err) } select { case <-bDone: case <-time.After(10 * time.Second): t.Fatalf("%s never fetched sub_1 itself, so nothing made B run: its snapshot came from somewhere else", a.name) } if bErr != nil { t.Fatalf("%s: %v", b.name, bErr) } if got := db.dump(`SELECT status FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_1'`); got != "canceled\n" { t.Errorf("status = %q, want canceled: the older snapshot was written last", got) } if hasPlan(t, s, "42", "annual") { t.Error("a canceled subscription still grants") } }) } }