package abono_test import ( "context" "fmt" "testing" "time" "gorm.io/gorm" "amadan.net/rastrillo/abono" ) const day = 24 * time.Hour // TestHoldingTable is every row of the design's section 3.3 for // subscriptions and comps, with the edges of each time rule. func TestHoldingTable(t *testing.T) { end := t0 // the paid period (or comp) ends at t0 in every case at := func(d time.Duration) time.Time { return end.Add(d) } ptr := func(v time.Time) *time.Time { return &v } type tc struct { name string source abono.Source status string end *time.Time // current_period_end, or a comp's ends_at start *time.Time // current_period_start unpaid *time.Time // unpaid_since cancel bool now time.Time grants bool backstop bool } cases := []tc{ {name: "active", status: "active", end: ptr(end), now: at(-day), grants: true}, {name: "active, period elapsed (renewal webhook late)", status: "active", end: ptr(end), now: at(2 * day), grants: true}, {name: "active, no period end", status: "active", now: at(0), grants: true}, {name: "active, cancelling, before the end", status: "active", end: ptr(end), cancel: true, now: at(-day), grants: true}, {name: "trialing", status: "trialing", end: ptr(end), now: at(-day), grants: true}, {name: "active, cancelling, one second before the period end", status: "active", end: ptr(end), cancel: true, now: at(-time.Second), grants: true}, {name: "active, cancelling, at the period end", status: "active", end: ptr(end), cancel: true, now: at(0), grants: false}, {name: "active, cancelling, one second after the period end", status: "active", end: ptr(end), cancel: true, now: at(time.Second), grants: false}, {name: "active, cancelling, a day after the period end", status: "active", end: ptr(end), cancel: true, now: at(day), grants: false}, {name: "trialing, cancelling, one second before the period end", status: "trialing", end: ptr(end), cancel: true, now: at(-time.Second), grants: true}, {name: "trialing, cancelling, at the period end", status: "trialing", end: ptr(end), cancel: true, now: at(0), grants: false}, {name: "trialing, cancelling, one second after the period end", status: "trialing", end: ptr(end), cancel: true, now: at(time.Second), grants: false}, {name: "trialing, cancelling, a day after the period end", status: "trialing", end: ptr(end), cancel: true, now: at(day), grants: false}, {name: "active, cancelling, no period end", status: "active", cancel: true, now: at(400 * day), grants: true}, // past_due (ruling R21): a failed renewal has already moved the // period on, so current_period_end is the unpaid period's end and // current_period_start the paid one's. The 30 days count from the // start; the end, a year off here, does not matter. {name: "past_due, cancelling, keeps the past_due rule", status: "past_due", start: ptr(end), end: ptr(end.AddDate(1, 0, 0)), cancel: true, now: at(10 * day), grants: true, backstop: true}, {name: "past_due before the period start", status: "past_due", start: ptr(end), end: ptr(end.AddDate(1, 0, 0)), now: at(-time.Second), grants: true}, {name: "past_due at the period start", status: "past_due", start: ptr(end), end: ptr(end.AddDate(1, 0, 0)), now: at(0), grants: true, backstop: true}, {name: "past_due, 29 days past the start", status: "past_due", start: ptr(end), end: ptr(end.AddDate(1, 0, 0)), now: at(29 * day), grants: true, backstop: true}, {name: "past_due, one second before start+30d", status: "past_due", start: ptr(end), end: ptr(end.AddDate(1, 0, 0)), now: at(30*day - time.Second), grants: true, backstop: true}, {name: "past_due at exactly start+30d", status: "past_due", start: ptr(end), end: ptr(end.AddDate(1, 0, 0)), now: at(30 * day), grants: false}, {name: "past_due past start+30d, the unpaid period's end still months off", status: "past_due", start: ptr(end), end: ptr(end.AddDate(1, 0, 0)), now: at(31 * day), grants: false}, // R30: with no period start the 30 days count from the period end; // with neither, nothing says when to stop, so it does not grant. {name: "past_due, no period start, before the period end", status: "past_due", end: ptr(end), now: at(-time.Second), grants: true}, {name: "past_due, no period start, at the period end", status: "past_due", end: ptr(end), now: at(0), grants: true, backstop: true}, {name: "past_due, no period start, one second before end+30d", status: "past_due", end: ptr(end), now: at(30*day - time.Second), grants: true, backstop: true}, {name: "past_due, no period start, at end+30d", status: "past_due", end: ptr(end), now: at(30 * day), grants: false}, {name: "past_due, no period start, long after", status: "past_due", end: ptr(end), now: at(400 * day), grants: false}, {name: "past_due with no period at all", status: "past_due", now: at(0), grants: false}, // R30: cancelling stops at the earlier of start+30d and the period // end. The period end is 10 days past the start here, so it is // sooner; in the case above (end a year off) start+30d is. {name: "past_due, cancelling, one second before the period end", status: "past_due", start: ptr(end.Add(-10 * day)), end: ptr(end), cancel: true, now: at(-time.Second), grants: true, backstop: true}, {name: "past_due, cancelling, at the period end", status: "past_due", start: ptr(end.Add(-10 * day)), end: ptr(end), cancel: true, now: at(0), grants: false}, {name: "past_due, same period, not cancelling, past the period end", status: "past_due", start: ptr(end.Add(-10 * day)), end: ptr(end), now: at(day), grants: true, backstop: true}, {name: "past_due, cancelling, start+30d sooner than the period end", status: "past_due", start: ptr(end), end: ptr(end.AddDate(1, 0, 0)), cancel: true, now: at(30 * day), grants: false}, {name: "past_due, cancelling, no period start, at the period end", status: "past_due", end: ptr(end), cancel: true, now: at(0), grants: false}, // R32: Stripe goes on invoicing a past_due subscription, and each // failed renewal moves the period start on. The 30 days count from // unpaid_since, the first unpaid start abono saw. {name: "past_due, unpaid since an earlier period, one second before unpaid_since+30d", status: "past_due", unpaid: ptr(end), start: ptr(end.Add(29 * day)), end: ptr(end.Add(59 * day)), now: at(30*day - time.Second), grants: true, backstop: true}, {name: "past_due, unpaid since an earlier period, at unpaid_since+30d though the start is a day old", status: "past_due", unpaid: ptr(end), start: ptr(end.Add(29 * day)), end: ptr(end.Add(59 * day)), now: at(30 * day), grants: false}, {name: "canceled", status: "canceled", end: ptr(end), now: at(-day), grants: false}, {name: "unpaid", status: "unpaid", end: ptr(end), now: at(-day), grants: false}, {name: "incomplete", status: "incomplete", end: ptr(end), now: at(-day), grants: false}, {name: "incomplete_expired", status: "incomplete_expired", end: ptr(end), now: at(-day), grants: false}, {name: "paused", status: "paused", end: ptr(end), now: at(-day), grants: false}, {name: "a status Stripe may add later", status: "future_status", end: ptr(end), now: at(-day), grants: false}, {name: "comp, no end", source: abono.SourceComp, status: "active", now: at(1000 * day), grants: true}, {name: "comp before its end", source: abono.SourceComp, status: "active", end: ptr(end), now: at(-time.Second), grants: true}, {name: "comp at its end", source: abono.SourceComp, status: "active", end: ptr(end), now: at(0), grants: false}, {name: "comp after its end", source: abono.SourceComp, status: "active", end: ptr(end), now: at(day), grants: false}, {name: "comp canceled", source: abono.SourceComp, status: "canceled", now: at(-day), grants: false}, {name: "comp canceled before its end", source: abono.SourceComp, status: "canceled", end: ptr(end), now: at(-day), grants: false}, } for i, c := range cases { t.Run(c.name, func(t *testing.T) { now := c.now s, db := newService(t, &now, abono.Config{}) ctx := context.Background() sub := fmt.Sprintf("s%d", i) if c.source == abono.SourceComp { db.exec(`INSERT INTO abono_comps (subject, plan, status, ends_at, created_at, updated_at) VALUES (?, 'annual', ?, ?, ?, ?)`, sub, c.status, c.end, t0, t0) } else { db.exec(`INSERT INTO abono_subscriptions (stripe_subscription_id, stripe_customer_id, subject, plan, stripe_price_id, status, current_period_start, current_period_end, cancel_at_period_end, unpaid_since, created_at, updated_at) VALUES ('sub_1', 'cus_1', ?, 'annual', 'price_A', ?, ?, ?, ?, ?, ?, ?)`, sub, c.status, c.start, c.end, c.cancel, c.unpaid, t0, t0) } hs, err := s.Holdings(ctx, sub) if err != nil { t.Fatalf("Holdings: %v", err) } if len(hs) != 1 { t.Fatalf("Holdings = %+v, want one", hs) } h := hs[0] wantSource := c.source if wantSource == "" { wantSource = abono.SourceSubscription } if h.Source != wantSource || h.Plan != "annual" || h.Status != c.status || h.CancelAtPeriodEnd != c.cancel { t.Errorf("holding = %+v", h) } if h.Grants != c.grants || h.Backstop != c.backstop { t.Errorf("Grants, Backstop = %v, %v; want %v, %v", h.Grants, h.Backstop, c.grants, c.backstop) } if (h.Until == nil) != (c.end == nil) || (h.Until != nil && !h.Until.Equal(*c.end)) { t.Errorf("Until = %v, want %v", h.Until, c.end) } if (h.PeriodStart == nil) != (c.start == nil) || (h.PeriodStart != nil && !h.PeriodStart.Equal(*c.start)) { t.Errorf("PeriodStart = %v, want %v", h.PeriodStart, c.start) } if h.Source == abono.SourceSubscription && h.SubscriptionID != "sub_1" || h.Source == abono.SourceComp && h.CompID == 0 { t.Errorf("ids missing: %+v", h) } if has, err := s.HasPlan(ctx, sub, "annual"); err != nil || has != c.grants { t.Errorf("HasPlan = %v, %v; want %v", has, err, c.grants) } if has, _ := s.HasPlan(ctx, sub, "monthly"); has { t.Error("HasPlan of a plan not held") } }) } } func TestUnrecognisedNeverGrants(t *testing.T) { now := t0 s, db := newService(t, &now, abono.Config{}) ctx := context.Background() future := t0.Add(30 * day) // A price no offer lists: plan = ''. db.exec(`INSERT INTO abono_subscriptions (stripe_subscription_id, stripe_customer_id, subject, plan, stripe_price_id, status, current_period_end, created_at, updated_at) VALUES ('sub_x', 'cus_1', 's1', '', 'price_unknown', 'active', ?, ?, ?)`, future, t0, t0) // Paid for, not yet claimed: subject = ''. db.exec(`INSERT INTO abono_subscriptions (stripe_subscription_id, stripe_customer_id, subject, plan, stripe_price_id, status, current_period_end, email, created_at, updated_at) VALUES ('sub_u', 'cus_2', '', 'annual', 'price_A', 'active', ?, 'a@example.com', ?, ?)`, future, t0, t0) hs, err := s.Holdings(ctx, "s1") if err != nil || len(hs) != 0 { t.Errorf("Holdings(s1) = %+v, %v; want none", hs, err) } if has, err := s.HasPlan(ctx, "s1", ""); err != nil || has { t.Errorf("HasPlan(s1, \"\") = %v, %v", has, err) } if has, err := s.HasPlan(ctx, "s1", "annual"); err != nil || has { t.Errorf("HasPlan(s1, annual) = %v, %v", has, err) } hs, err = s.Holdings(ctx, "") if err != nil || len(hs) != 0 { t.Errorf("Holdings of no subject = %+v, %v; want none", hs, err) } if has, err := s.HasPlan(ctx, "", "annual"); err != nil || has { t.Errorf("HasPlan of no subject = %v, %v", has, err) } } func TestHoldingsNewestFirstAndOnlyThisSubjects(t *testing.T) { now := t0 s, db := newService(t, &now, abono.Config{}) ctx := context.Background() old, mid, recent := t0.Add(-3*day), t0.Add(-2*day), t0.Add(-day) db.exec(`INSERT INTO abono_subscriptions (stripe_subscription_id, stripe_customer_id, subject, plan, stripe_price_id, status, created_at, updated_at) VALUES ('sub_old', 'cus_1', 's1', 'monthly', 'price_B', 'canceled', ?, ?)`, old, old) db.exec(`INSERT INTO abono_subscriptions (stripe_subscription_id, stripe_customer_id, subject, plan, stripe_price_id, status, created_at, updated_at) VALUES ('sub_new', 'cus_1', 's1', 'annual', 'price_A', 'active', ?, ?)`, recent, recent) db.exec(`INSERT INTO abono_comps (subject, plan, status, created_at, updated_at) VALUES ('s1', 'friend', 'active', ?, ?)`, mid, mid) db.exec(`INSERT INTO abono_comps (subject, plan, status, created_at, updated_at) VALUES ('s2', 'friend', 'active', ?, ?)`, mid, mid) hs, err := s.Holdings(ctx, "s1") if err != nil { t.Fatal(err) } var got []string for _, h := range hs { got = append(got, h.Plan) } if fmt.Sprint(got) != "[annual friend monthly]" { t.Errorf("plans newest first = %v", got) } } // TestOnChangeReadsItsOwnTransactionWithHasPlanIn is the pattern SKILL.md // gives OnChange: Holdings and HasPlan use the service's own handle, which // does not join the transaction OnChange runs in and so waits on it forever // (one writer connection); HoldingsIn and HasPlanIn read through the tx // OnChange was handed, see the write that triggered it, and judge it by the // service's clock. func TestOnChangeReadsItsOwnTransactionWithHasPlanIn(t *testing.T) { now := t0 d := newStripeDouble(t) type seen struct { has bool holding []abono.Holding } var ( s *abono.Service outcomes []seen ) onChange := func(ctx context.Context, tx *gorm.DB, subject string) error { has, err := s.HasPlanIn(ctx, tx, subject, "annual") if err != nil { return err } hs, err := s.HoldingsIn(ctx, tx, subject) if err != nil { return err } outcomes = append(outcomes, seen{has, hs}) return nil } s, _ = d.service(t, &now, abono.Config{OnChange: onChange}) seedPlans(t, s) sub := d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", Start: t0.Add(-day), End: t0.Add(300 * day)}) done := make(chan error, 1) go func() { done <- s.Sync(context.Background(), sub, abono.Hint{Subject: "ana"}) }() select { case err := <-done: if err != nil { t.Fatalf("Sync: %v", err) } case <-time.After(10 * time.Second): t.Fatal("Sync did not finish: OnChange waits on its own transaction") } if len(outcomes) == 0 { t.Fatal("OnChange was never called") } last := outcomes[len(outcomes)-1] if !last.has || len(last.holding) != 1 || !last.holding[0].Grants { t.Errorf("OnChange saw has=%v holdings=%+v after the sync; want the new subscription granting", last.has, last.holding) } // The service's clock decides, inside the transaction too: once the // period has long run out, a cancelling subscription synced then is // seen as not granting. now = t0.Add(400 * day) outcomes = nil ended := d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", Start: t0.Add(-day), End: t0.Add(300 * day), Cancel: true}) go func() { done <- s.Sync(context.Background(), ended, abono.Hint{}) }() select { case err := <-done: if err != nil { t.Fatalf("Sync (cancelling): %v", err) } case <-time.After(10 * time.Second): t.Fatal("Sync did not finish the second time") } if len(outcomes) == 0 { t.Fatal("OnChange was not called for the cancelling subscription") } if last := outcomes[len(outcomes)-1]; last.has { t.Errorf("OnChange saw has=true for a cancelling subscription 100 days past its period end") } }