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 "testing"
7 "time"
8
9 "gorm.io/gorm"
10
11 "amadan.net/rastrillo/abono"
12)
13
14const 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.
18func 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
149func 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
181func 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.
213func 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