| 1 | package abono_test |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "gorm.io/gorm" |
| 10 | |
| 11 | "amadan.net/rastrillo/abono" |
| 12 | ) |
| 13 | |
| 14 | const day = 24 * time.Hour |
| 15 | |
| 16 | // TestHoldingTable is every row of the design's section 3.3 for |
| 17 | // subscriptions and comps, with the edges of each time rule. |
| 18 | func TestHoldingTable(t *testing.T) { |
| 19 | end := t0 // the paid period (or comp) ends at t0 in every case |
| 20 | at := func(d time.Duration) time.Time { return end.Add(d) } |
| 21 | ptr := func(v time.Time) *time.Time { return &v } |
| 22 | |
| 23 | type tc struct { |
| 24 | name string |
| 25 | source abono.Source |
| 26 | status string |
| 27 | end *time.Time // current_period_end, or a comp's ends_at |
| 28 | start *time.Time // current_period_start |
| 29 | unpaid *time.Time // unpaid_since |
| 30 | cancel bool |
| 31 | now time.Time |
| 32 | grants bool |
| 33 | backstop bool |
| 34 | } |
| 35 | cases := []tc{ |
| 36 | {name: "active", status: "active", end: ptr(end), now: at(-day), grants: true}, |
| 37 | {name: "active, period elapsed (renewal webhook late)", status: "active", end: ptr(end), now: at(2 * day), grants: true}, |
| 38 | {name: "active, no period end", status: "active", now: at(0), grants: true}, |
| 39 | {name: "active, cancelling, before the end", status: "active", end: ptr(end), cancel: true, now: at(-day), grants: true}, |
| 40 | {name: "trialing", status: "trialing", end: ptr(end), now: at(-day), grants: true}, |
| 41 | {name: "active, cancelling, one second before the period end", status: "active", end: ptr(end), cancel: true, now: at(-time.Second), grants: true}, |
| 42 | {name: "active, cancelling, at the period end", status: "active", end: ptr(end), cancel: true, now: at(0), grants: false}, |
| 43 | {name: "active, cancelling, one second after the period end", status: "active", end: ptr(end), cancel: true, now: at(time.Second), grants: false}, |
| 44 | {name: "active, cancelling, a day after the period end", status: "active", end: ptr(end), cancel: true, now: at(day), grants: false}, |
| 45 | {name: "trialing, cancelling, one second before the period end", status: "trialing", end: ptr(end), cancel: true, now: at(-time.Second), grants: true}, |
| 46 | {name: "trialing, cancelling, at the period end", status: "trialing", end: ptr(end), cancel: true, now: at(0), grants: false}, |
| 47 | {name: "trialing, cancelling, one second after the period end", status: "trialing", end: ptr(end), cancel: true, now: at(time.Second), grants: false}, |
| 48 | {name: "trialing, cancelling, a day after the period end", status: "trialing", end: ptr(end), cancel: true, now: at(day), grants: false}, |
| 49 | {name: "active, cancelling, no period end", status: "active", cancel: true, now: at(400 * day), grants: true}, |
| 50 | // past_due (ruling R21): a failed renewal has already moved the |
| 51 | // period on, so current_period_end is the unpaid period's end and |
| 52 | // current_period_start the paid one's. The 30 days count from the |
| 53 | // start; the end, a year off here, does not matter. |
| 54 | {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}, |
| 55 | {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}, |
| 56 | {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}, |
| 57 | {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}, |
| 58 | {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}, |
| 59 | {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}, |
| 60 | {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}, |
| 61 | // R30: with no period start the 30 days count from the period end; |
| 62 | // with neither, nothing says when to stop, so it does not grant. |
| 63 | {name: "past_due, no period start, before the period end", status: "past_due", end: ptr(end), now: at(-time.Second), grants: true}, |
| 64 | {name: "past_due, no period start, at the period end", status: "past_due", end: ptr(end), now: at(0), grants: true, backstop: true}, |
| 65 | {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}, |
| 66 | {name: "past_due, no period start, at end+30d", status: "past_due", end: ptr(end), now: at(30 * day), grants: false}, |
| 67 | {name: "past_due, no period start, long after", status: "past_due", end: ptr(end), now: at(400 * day), grants: false}, |
| 68 | {name: "past_due with no period at all", status: "past_due", now: at(0), grants: false}, |
| 69 | // R30: cancelling stops at the earlier of start+30d and the period |
| 70 | // end. The period end is 10 days past the start here, so it is |
| 71 | // sooner; in the case above (end a year off) start+30d is. |
| 72 | {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}, |
| 73 | {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}, |
| 74 | {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}, |
| 75 | {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}, |
| 76 | {name: "past_due, cancelling, no period start, at the period end", status: "past_due", end: ptr(end), cancel: true, now: at(0), grants: false}, |
| 77 | // R32: Stripe goes on invoicing a past_due subscription, and each |
| 78 | // failed renewal moves the period start on. The 30 days count from |
| 79 | // unpaid_since, the first unpaid start abono saw. |
| 80 | {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}, |
| 81 | {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}, |
| 82 | {name: "canceled", status: "canceled", end: ptr(end), now: at(-day), grants: false}, |
| 83 | {name: "unpaid", status: "unpaid", end: ptr(end), now: at(-day), grants: false}, |
| 84 | {name: "incomplete", status: "incomplete", end: ptr(end), now: at(-day), grants: false}, |
| 85 | {name: "incomplete_expired", status: "incomplete_expired", end: ptr(end), now: at(-day), grants: false}, |
| 86 | {name: "paused", status: "paused", end: ptr(end), now: at(-day), grants: false}, |
| 87 | {name: "a status Stripe may add later", status: "future_status", end: ptr(end), now: at(-day), grants: false}, |
| 88 | |
| 89 | {name: "comp, no end", source: abono.SourceComp, status: "active", now: at(1000 * day), grants: true}, |
| 90 | {name: "comp before its end", source: abono.SourceComp, status: "active", end: ptr(end), now: at(-time.Second), grants: true}, |
| 91 | {name: "comp at its end", source: abono.SourceComp, status: "active", end: ptr(end), now: at(0), grants: false}, |
| 92 | {name: "comp after its end", source: abono.SourceComp, status: "active", end: ptr(end), now: at(day), grants: false}, |
| 93 | {name: "comp canceled", source: abono.SourceComp, status: "canceled", now: at(-day), grants: false}, |
| 94 | {name: "comp canceled before its end", source: abono.SourceComp, status: "canceled", end: ptr(end), now: at(-day), grants: false}, |
| 95 | } |
| 96 | |
| 97 | for i, c := range cases { |
| 98 | t.Run(c.name, func(t *testing.T) { |
| 99 | now := c.now |
| 100 | s, db := newService(t, &now, abono.Config{}) |
| 101 | ctx := context.Background() |
| 102 | sub := fmt.Sprintf("s%d", i) |
| 103 | if c.source == abono.SourceComp { |
| 104 | db.exec(`INSERT INTO abono_comps (subject, plan, status, ends_at, created_at, updated_at) VALUES (?, 'annual', ?, ?, ?, ?)`, |
| 105 | sub, c.status, c.end, t0, t0) |
| 106 | } else { |
| 107 | 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) |
| 108 | VALUES ('sub_1', 'cus_1', ?, 'annual', 'price_A', ?, ?, ?, ?, ?, ?, ?)`, |
| 109 | sub, c.status, c.start, c.end, c.cancel, c.unpaid, t0, t0) |
| 110 | } |
| 111 | |
| 112 | hs, err := s.Holdings(ctx, sub) |
| 113 | if err != nil { |
| 114 | t.Fatalf("Holdings: %v", err) |
| 115 | } |
| 116 | if len(hs) != 1 { |
| 117 | t.Fatalf("Holdings = %+v, want one", hs) |
| 118 | } |
| 119 | h := hs[0] |
| 120 | wantSource := c.source |
| 121 | if wantSource == "" { |
| 122 | wantSource = abono.SourceSubscription |
| 123 | } |
| 124 | if h.Source != wantSource || h.Plan != "annual" || h.Status != c.status || h.CancelAtPeriodEnd != c.cancel { |
| 125 | t.Errorf("holding = %+v", h) |
| 126 | } |
| 127 | if h.Grants != c.grants || h.Backstop != c.backstop { |
| 128 | t.Errorf("Grants, Backstop = %v, %v; want %v, %v", h.Grants, h.Backstop, c.grants, c.backstop) |
| 129 | } |
| 130 | if (h.Until == nil) != (c.end == nil) || (h.Until != nil && !h.Until.Equal(*c.end)) { |
| 131 | t.Errorf("Until = %v, want %v", h.Until, c.end) |
| 132 | } |
| 133 | if (h.PeriodStart == nil) != (c.start == nil) || (h.PeriodStart != nil && !h.PeriodStart.Equal(*c.start)) { |
| 134 | t.Errorf("PeriodStart = %v, want %v", h.PeriodStart, c.start) |
| 135 | } |
| 136 | if h.Source == abono.SourceSubscription && h.SubscriptionID != "sub_1" || h.Source == abono.SourceComp && h.CompID == 0 { |
| 137 | t.Errorf("ids missing: %+v", h) |
| 138 | } |
| 139 | if has, err := s.HasPlan(ctx, sub, "annual"); err != nil || has != c.grants { |
| 140 | t.Errorf("HasPlan = %v, %v; want %v", has, err, c.grants) |
| 141 | } |
| 142 | if has, _ := s.HasPlan(ctx, sub, "monthly"); has { |
| 143 | t.Error("HasPlan of a plan not held") |
| 144 | } |
| 145 | }) |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | func TestUnrecognisedNeverGrants(t *testing.T) { |
| 150 | now := t0 |
| 151 | s, db := newService(t, &now, abono.Config{}) |
| 152 | ctx := context.Background() |
| 153 | future := t0.Add(30 * day) |
| 154 | |
| 155 | // A price no offer lists: plan = ''. |
| 156 | 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) |
| 157 | VALUES ('sub_x', 'cus_1', 's1', '', 'price_unknown', 'active', ?, ?, ?)`, future, t0, t0) |
| 158 | // Paid for, not yet claimed: subject = ''. |
| 159 | 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) |
| 160 | VALUES ('sub_u', 'cus_2', '', 'annual', 'price_A', 'active', ?, 'a@example.com', ?, ?)`, future, t0, t0) |
| 161 | |
| 162 | hs, err := s.Holdings(ctx, "s1") |
| 163 | if err != nil || len(hs) != 0 { |
| 164 | t.Errorf("Holdings(s1) = %+v, %v; want none", hs, err) |
| 165 | } |
| 166 | if has, err := s.HasPlan(ctx, "s1", ""); err != nil || has { |
| 167 | t.Errorf("HasPlan(s1, \"\") = %v, %v", has, err) |
| 168 | } |
| 169 | if has, err := s.HasPlan(ctx, "s1", "annual"); err != nil || has { |
| 170 | t.Errorf("HasPlan(s1, annual) = %v, %v", has, err) |
| 171 | } |
| 172 | hs, err = s.Holdings(ctx, "") |
| 173 | if err != nil || len(hs) != 0 { |
| 174 | t.Errorf("Holdings of no subject = %+v, %v; want none", hs, err) |
| 175 | } |
| 176 | if has, err := s.HasPlan(ctx, "", "annual"); err != nil || has { |
| 177 | t.Errorf("HasPlan of no subject = %v, %v", has, err) |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | func TestHoldingsNewestFirstAndOnlyThisSubjects(t *testing.T) { |
| 182 | now := t0 |
| 183 | s, db := newService(t, &now, abono.Config{}) |
| 184 | ctx := context.Background() |
| 185 | |
| 186 | old, mid, recent := t0.Add(-3*day), t0.Add(-2*day), t0.Add(-day) |
| 187 | db.exec(`INSERT INTO abono_subscriptions (stripe_subscription_id, stripe_customer_id, subject, plan, stripe_price_id, status, created_at, updated_at) |
| 188 | VALUES ('sub_old', 'cus_1', 's1', 'monthly', 'price_B', 'canceled', ?, ?)`, old, old) |
| 189 | db.exec(`INSERT INTO abono_subscriptions (stripe_subscription_id, stripe_customer_id, subject, plan, stripe_price_id, status, created_at, updated_at) |
| 190 | VALUES ('sub_new', 'cus_1', 's1', 'annual', 'price_A', 'active', ?, ?)`, recent, recent) |
| 191 | db.exec(`INSERT INTO abono_comps (subject, plan, status, created_at, updated_at) VALUES ('s1', 'friend', 'active', ?, ?)`, mid, mid) |
| 192 | db.exec(`INSERT INTO abono_comps (subject, plan, status, created_at, updated_at) VALUES ('s2', 'friend', 'active', ?, ?)`, mid, mid) |
| 193 | |
| 194 | hs, err := s.Holdings(ctx, "s1") |
| 195 | if err != nil { |
| 196 | t.Fatal(err) |
| 197 | } |
| 198 | var got []string |
| 199 | for _, h := range hs { |
| 200 | got = append(got, h.Plan) |
| 201 | } |
| 202 | if fmt.Sprint(got) != "[annual friend monthly]" { |
| 203 | t.Errorf("plans newest first = %v", got) |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | // TestOnChangeReadsItsOwnTransactionWithHasPlanIn is the pattern SKILL.md |
| 208 | // gives OnChange: Holdings and HasPlan use the service's own handle, which |
| 209 | // does not join the transaction OnChange runs in and so waits on it forever |
| 210 | // (one writer connection); HoldingsIn and HasPlanIn read through the tx |
| 211 | // OnChange was handed, see the write that triggered it, and judge it by the |
| 212 | // service's clock. |
| 213 | func TestOnChangeReadsItsOwnTransactionWithHasPlanIn(t *testing.T) { |
| 214 | now := t0 |
| 215 | d := newStripeDouble(t) |
| 216 | type seen struct { |
| 217 | has bool |
| 218 | holding []abono.Holding |
| 219 | } |
| 220 | var ( |
| 221 | s *abono.Service |
| 222 | outcomes []seen |
| 223 | ) |
| 224 | onChange := func(ctx context.Context, tx *gorm.DB, subject string) error { |
| 225 | has, err := s.HasPlanIn(ctx, tx, subject, "annual") |
| 226 | if err != nil { |
| 227 | return err |
| 228 | } |
| 229 | hs, err := s.HoldingsIn(ctx, tx, subject) |
| 230 | if err != nil { |
| 231 | return err |
| 232 | } |
| 233 | outcomes = append(outcomes, seen{has, hs}) |
| 234 | return nil |
| 235 | } |
| 236 | s, _ = d.service(t, &now, abono.Config{OnChange: onChange}) |
| 237 | seedPlans(t, s) |
| 238 | |
| 239 | sub := d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", |
| 240 | Start: t0.Add(-day), End: t0.Add(300 * day)}) |
| 241 | done := make(chan error, 1) |
| 242 | go func() { done <- s.Sync(context.Background(), sub, abono.Hint{Subject: "ana"}) }() |
| 243 | select { |
| 244 | case err := <-done: |
| 245 | if err != nil { |
| 246 | t.Fatalf("Sync: %v", err) |
| 247 | } |
| 248 | case <-time.After(10 * time.Second): |
| 249 | t.Fatal("Sync did not finish: OnChange waits on its own transaction") |
| 250 | } |
| 251 | |
| 252 | if len(outcomes) == 0 { |
| 253 | t.Fatal("OnChange was never called") |
| 254 | } |
| 255 | last := outcomes[len(outcomes)-1] |
| 256 | if !last.has || len(last.holding) != 1 || !last.holding[0].Grants { |
| 257 | t.Errorf("OnChange saw has=%v holdings=%+v after the sync; want the new subscription granting", last.has, last.holding) |
| 258 | } |
| 259 | |
| 260 | // The service's clock decides, inside the transaction too: once the |
| 261 | // period has long run out, a cancelling subscription synced then is |
| 262 | // seen as not granting. |
| 263 | now = t0.Add(400 * day) |
| 264 | outcomes = nil |
| 265 | ended := d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", |
| 266 | Start: t0.Add(-day), End: t0.Add(300 * day), Cancel: true}) |
| 267 | go func() { done <- s.Sync(context.Background(), ended, abono.Hint{}) }() |
| 268 | select { |
| 269 | case err := <-done: |
| 270 | if err != nil { |
| 271 | t.Fatalf("Sync (cancelling): %v", err) |
| 272 | } |
| 273 | case <-time.After(10 * time.Second): |
| 274 | t.Fatal("Sync did not finish the second time") |
| 275 | } |
| 276 | if len(outcomes) == 0 { |
| 277 | t.Fatal("OnChange was not called for the cancelling subscription") |
| 278 | } |
| 279 | if last := outcomes[len(outcomes)-1]; last.has { |
| 280 | t.Errorf("OnChange saw has=true for a cancelling subscription 100 days past its period end") |
| 281 | } |
| 282 | } |
| 283 | |