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 "errors"
6 "net/url"
7 "reflect"
8 "testing"
9 "time"
10
11 "amadan.net/rastrillo/abono"
12)
13
14func TestCheckoutForm(t *testing.T) {
15 now := t0
16 d := newStripeDouble(t)
17 d.AddPromotionCode("INNOVATE", "promo_1")
18 s, db := d.service(t, &now, abono.Config{Origin: "https://site.example"})
19 seedPlans(t, s)
20 ctx := context.Background()
21 db.exec(`INSERT INTO abono_customers (stripe_customer_id, subject) VALUES ('cus_known', '5')`)
22
23 base := url.Values{
24 "mode": {"subscription"},
25 "line_items[0][price]": {"price_A"},
26 "line_items[0][quantity]": {"1"},
27 "success_url": {"https://site.example/join/done?session_id={CHECKOUT_SESSION_ID}"},
28 "cancel_url": {"https://site.example/join/annual"},
29 "subscription_data[metadata][abono_plan]": {"annual"},
30 // Card only (ruling R33): settlement is synchronous, so a
31 // subscription Stripe calls active has been paid for.
32 "payment_method_types[0]": {"card"},
33 }
34 with := func(kv ...string) url.Values {
35 v := url.Values{}
36 for k, vs := range base {
37 v[k] = vs
38 }
39 for i := 0; i < len(kv); i += 2 {
40 v.Set(kv[i], kv[i+1])
41 }
42 return v
43 }
44
45 for _, c := range []struct {
46 name string
47 req abono.CheckoutRequest
48 want url.Values
49 }{
50 {
51 name: "signed out, no code",
52 req: abono.CheckoutRequest{Plan: "annual"},
53 want: with("allow_promotion_codes", "true"),
54 },
55 {
56 name: "signed out, with a code",
57 req: abono.CheckoutRequest{Plan: "annual", PromotionCode: " INNOVATE "},
58 want: with("discounts[0][promotion_code]", "promo_1"),
59 },
60 {
61 name: "signed in, a known customer",
62 req: abono.CheckoutRequest{Plan: "annual", Subject: "5", Email: "five@example.com"},
63 want: with("allow_promotion_codes", "true", "client_reference_id", "5",
64 "subscription_data[metadata][abono_subject]", "5", "customer", "cus_known"),
65 },
66 {
67 name: "signed in, no customer yet, with a code",
68 req: abono.CheckoutRequest{Plan: "annual", Subject: "6", Email: "six@example.com", PromotionCode: "INNOVATE"},
69 want: with("discounts[0][promotion_code]", "promo_1", "client_reference_id", "6",
70 "subscription_data[metadata][abono_subject]", "6", "customer_email", "six@example.com"),
71 },
72 } {
73 t.Run(c.name, func(t *testing.T) {
74 c.req.SuccessPath, c.req.CancelPath = "/join/done", "/join/annual"
75 u, err := s.Checkout(ctx, c.req)
76 if err != nil {
77 t.Fatalf("Checkout: %v", err)
78 }
79 if u == "" {
80 t.Error("Checkout returned no URL")
81 }
82 if got := d.lastCheckout(t); got.Encode() != c.want.Encode() {
83 t.Errorf("form =\n%s\nwant\n%s", got.Encode(), c.want.Encode())
84 }
85 })
86 }
87
88 if _, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", PromotionCode: "NOPE"}); !errors.Is(err, abono.ErrUnknownCode) {
89 t.Errorf("an unknown code: err = %v, want ErrUnknownCode", err)
90 }
91 if _, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "lifetime"}); !errors.Is(err, abono.ErrUnknownPlan) {
92 t.Errorf("an unknown plan: err = %v, want ErrUnknownPlan", err)
93 }
94}
95
96// TestCheckoutChoosesThePriceByInterval is rulings R15 and R17: Checkout
97// first learns the interval of any sold price it does not know yet, then
98// an interval picks the sold price with it, and no interval needs exactly
99// one sold price.
100func TestCheckoutChoosesThePriceByInterval(t *testing.T) {
101 now := t0
102 d := newStripeDouble(t)
103 s, db := d.service(t, &now, abono.Config{})
104 seedPlans(t, s)
105 ctx := context.Background()
106 req := func(plan, interval string) abono.CheckoutRequest {
107 return abono.CheckoutRequest{Plan: plan, Interval: interval, SuccessPath: "/done", CancelPath: "/"}
108 }
109
110 // The plan sold yearly and monthly, neither interval known yet: a
111 // second sold price recorded without a read, as a seed records one.
112 d.price("price_A_month", "month", 600, "eur")
113 db.exec(`INSERT INTO abono_prices (stripe_price_id, plan, sold) VALUES ('price_A_month', 'annual', 1)`)
114 if got := db.dump(`SELECT stripe_price_id, interval FROM abono_prices WHERE plan = 'annual' AND sold = 1 ORDER BY stripe_price_id`); got != "price_A|\nprice_A_month|\n" {
115 t.Fatalf("sold prices before Checkout = %q, want both intervals unknown", got)
116 }
117
118 if _, err := s.Checkout(ctx, req("annual", "year")); err != nil {
119 t.Fatalf("interval year: %v", err)
120 }
121 if got := d.lastCheckout(t).Get("line_items[0][price]"); got != "price_A" {
122 t.Errorf("year: price = %q, want price_A", got)
123 }
124 if got := d.fetches(); !reflect.DeepEqual(got, map[string]int{"price_A": 1, "price_A_month": 1}) {
125 t.Errorf("price fetches = %v, want one each of the sold prices", got)
126 }
127 if got := db.dump(`SELECT stripe_price_id, interval, amount, currency, refreshed_at IS NOT NULL FROM abono_prices ORDER BY stripe_price_id`); got !=
128 "price_A|year|6000|eur|1\nprice_A_month|month|600|eur|1\nprice_M||0||0\nprice_old||0||0\n" {
129 t.Errorf("prices after Checkout =\n%s", got)
130 }
131
132 // Known now: no more reads.
133 if _, err := s.Checkout(ctx, req("annual", "month")); err != nil {
134 t.Fatalf("interval month: %v", err)
135 }
136 if got := d.lastCheckout(t).Get("line_items[0][price]"); got != "price_A_month" {
137 t.Errorf("month: price = %q, want price_A_month", got)
138 }
139 if got := d.fetches(); !reflect.DeepEqual(got, map[string]int{"price_A": 1, "price_A_month": 1}) {
140 t.Errorf("price fetches after a second Checkout = %v, want no more", got)
141 }
142
143 if _, err := s.Checkout(ctx, req("annual", "")); !errors.Is(err, abono.ErrUnknownPlan) {
144 t.Errorf("two sold prices and no interval: err = %v, want ErrUnknownPlan", err)
145 }
146 if _, err := s.Checkout(ctx, req("annual", "week")); !errors.Is(err, abono.ErrUnknownPlan) {
147 t.Errorf("an interval the plan is not sold at: err = %v, want ErrUnknownPlan", err)
148 }
149
150 // One sold price, no interval asked for: that one.
151 if _, err := s.Checkout(ctx, req("monthly", "")); err != nil {
152 t.Fatalf("one sold price: %v", err)
153 }
154 if got := d.lastCheckout(t).Get("line_items[0][price]"); got != "price_M" {
155 t.Errorf("monthly: price = %q, want price_M", got)
156 }
157
158 // A legacy price is never sold.
159 db.exec(`UPDATE abono_prices SET sold = 0 WHERE plan = 'monthly'`)
160 if _, err := s.Checkout(ctx, req("monthly", "")); !errors.Is(err, abono.ErrUnknownPlan) {
161 t.Errorf("a plan with no sold price: err = %v, want ErrUnknownPlan", err)
162 }
163
164 // A price with no interval at Stripe (a one-off) is read once, not on
165 // every sale: refreshed_at, not the interval, says it is known (R19).
166 d.price("price_once", "", 500, "eur")
167 if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "once", Sold: "price_once"}}); err != nil {
168 t.Fatalf("SeedPlans: %v", err)
169 }
170 for range 2 {
171 if _, err := s.Checkout(ctx, req("once", "")); err != nil {
172 t.Fatalf("one-off price: %v", err)
173 }
174 }
175 if got := d.fetches()["price_once"]; got != 1 {
176 t.Errorf("price_once read %d times, want once", got)
177 }
178
179 // A sold price Stripe no longer has stops the sale rather than guessing.
180 db.exec(`INSERT INTO abono_prices (stripe_price_id, plan, sold) VALUES ('price_gone', 'annual', 1)`)
181 if _, err := s.Checkout(ctx, req("annual", "year")); err == nil {
182 t.Error("Checkout with a sold price Stripe cannot find succeeded")
183 }
184}
185
186// TestCheckoutSuccessURL: session_id goes in the query, before any
187// fragment, replacing one already there, and Stripe's placeholder reaches
188// it with its braces.
189func TestCheckoutSuccessURL(t *testing.T) {
190 now := t0
191 d := newStripeDouble(t)
192 s, _ := d.service(t, &now, abono.Config{Origin: "https://site.example/"})
193 seedPlans(t, s)
194 ctx := context.Background()
195
196 for path, want := range map[string]string{
197 "": "https://site.example/?session_id={CHECKOUT_SESSION_ID}",
198 "/done": "https://site.example/done?session_id={CHECKOUT_SESSION_ID}",
199 "/done#receipt": "https://site.example/done?session_id={CHECKOUT_SESSION_ID}#receipt",
200 "/done?session_id=old&x=1": "https://site.example/done?x=1&session_id={CHECKOUT_SESSION_ID}",
201 "/done?a=1&session_id=old#f": "https://site.example/done?a=1&session_id={CHECKOUT_SESSION_ID}#f",
202 } {
203 if _, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", SuccessPath: path}); err != nil {
204 t.Fatalf("SuccessPath %q: %v", path, err)
205 }
206 if got := d.lastCheckout(t).Get("success_url"); got != want {
207 t.Errorf("SuccessPath %q: success_url = %q, want %q", path, got, want)
208 }
209 }
210 for _, bad := range []string{"done", "//evil.example/x", "https://evil.example/"} {
211 if _, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", SuccessPath: bad}); err == nil {
212 t.Errorf("SuccessPath %q accepted", bad)
213 }
214 }
215}
216
217// TestPortalIsPerCustomer is final review I4 and I5 (ruling R37). A
218// reader may pay through several Stripe customers (two guest purchases, an
219// imported member), and a subscription on a price no plan has is still
220// charged. Customers lists every customer of the subject's subscription
221// rows, recognised or not, and Portal opens any of them, and only them.
222func TestPortalIsPerCustomer(t *testing.T) {
223 now := t0
224 d := newStripeDouble(t)
225 s, _ := d.service(t, &now, abono.Config{Origin: "https://site.example"})
226 seedPlans(t, s)
227 ctx := context.Background()
228
229 if _, err := s.Portal(ctx, "5", "cus_a", "/account"); !errors.Is(err, abono.ErrNoCustomer) {
230 t.Errorf("Portal with no subscription: err = %v, want ErrNoCustomer", err)
231 }
232 if cs, err := s.Customers(ctx, "5"); err != nil || len(cs) != 0 {
233 t.Errorf("Customers with no subscription = %q, %v", cs, err)
234 }
235
236 // An older customer still paying, a newer one whose subscription has
237 // ended, a third on a price no plan has, and someone else's.
238 sync := func(sp subSpec, subject string) {
239 t.Helper()
240 if err := s.Sync(ctx, d.put(t, sp), abono.Hint{Subject: subject}); err != nil {
241 t.Fatalf("Sync %s: %v", sp.ID, err)
242 }
243 now = now.Add(time.Minute)
244 }
245 sync(subSpec{ID: "sub_a", Status: "active", Price: "price_A", Customer: "cus_a", Email: "five@example.com", End: t0.Add(300 * day)}, "5")
246 sync(subSpec{ID: "sub_b", Status: "active", Price: "price_M", Customer: "cus_b", Email: "five@example.com", End: t0.Add(30 * day)}, "5")
247 sync(subSpec{ID: "sub_b", Status: "canceled", Price: "price_M", Customer: "cus_b", Email: "five@example.com", End: t0.Add(30 * day)}, "5")
248 sync(subSpec{ID: "sub_c", Status: "active", Price: "price_Z", Customer: "cus_c", Email: "five@example.com", End: t0.Add(30 * day)}, "5")
249 sync(subSpec{ID: "sub_x", Status: "active", Price: "price_A", Customer: "cus_x", Email: "six@example.com", End: t0.Add(30 * day)}, "6")
250
251 cs, err := s.Customers(ctx, "5")
252 if err != nil {
253 t.Fatalf("Customers: %v", err)
254 }
255 if want := []string{"cus_c", "cus_b", "cus_a"}; !reflect.DeepEqual(cs, want) {
256 t.Errorf("Customers(5) = %q, want %q (newest subscription first)", cs, want)
257 }
258 for _, c := range []string{"cus_a", "cus_b", "cus_c"} {
259 u, err := s.Portal(ctx, "5", c, "/account")
260 if err != nil {
261 t.Fatalf("Portal(5, %s): %v", c, err)
262 }
263 if u != d.URL+"/portal/"+c {
264 t.Errorf("Portal(5, %s) = %q", c, u)
265 }
266 forms := d.PortalForms()
267 if got := forms[len(forms)-1]; got.Get("customer") != c || got.Get("return_url") != "https://site.example/account" {
268 t.Errorf("portal form = %v", got)
269 }
270 }
271 for _, c := range []string{"cus_x", "", "cus_nobody"} {
272 if _, err := s.Portal(ctx, "5", c, "/account"); !errors.Is(err, abono.ErrNoCustomer) {
273 t.Errorf("Portal(5, %q): err = %v, want ErrNoCustomer", c, err)
274 }
275 }
276 if _, err := s.Portal(ctx, "", "cus_a", "/account"); !errors.Is(err, abono.ErrNoCustomer) {
277 t.Errorf("Portal for nobody: err = %v, want ErrNoCustomer", err)
278 }
279}
280