| 1 | package abono_test |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "net/url" |
| 7 | "reflect" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "amadan.net/rastrillo/abono" |
| 12 | ) |
| 13 | |
| 14 | func 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. |
| 100 | func 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. |
| 189 | func 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. |
| 222 | func 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 | |