package abono_test import ( "context" "errors" "net/url" "reflect" "testing" "time" "amadan.net/rastrillo/abono" ) func TestCheckoutForm(t *testing.T) { now := t0 d := newStripeDouble(t) d.AddPromotionCode("INNOVATE", "promo_1") s, db := d.service(t, &now, abono.Config{Origin: "https://site.example"}) seedPlans(t, s) ctx := context.Background() db.exec(`INSERT INTO abono_customers (stripe_customer_id, subject) VALUES ('cus_known', '5')`) base := url.Values{ "mode": {"subscription"}, "line_items[0][price]": {"price_A"}, "line_items[0][quantity]": {"1"}, "success_url": {"https://site.example/join/done?session_id={CHECKOUT_SESSION_ID}"}, "cancel_url": {"https://site.example/join/annual"}, "subscription_data[metadata][abono_plan]": {"annual"}, // Card only (ruling R33): settlement is synchronous, so a // subscription Stripe calls active has been paid for. "payment_method_types[0]": {"card"}, } with := func(kv ...string) url.Values { v := url.Values{} for k, vs := range base { v[k] = vs } for i := 0; i < len(kv); i += 2 { v.Set(kv[i], kv[i+1]) } return v } for _, c := range []struct { name string req abono.CheckoutRequest want url.Values }{ { name: "signed out, no code", req: abono.CheckoutRequest{Plan: "annual"}, want: with("allow_promotion_codes", "true"), }, { name: "signed out, with a code", req: abono.CheckoutRequest{Plan: "annual", PromotionCode: " INNOVATE "}, want: with("discounts[0][promotion_code]", "promo_1"), }, { name: "signed in, a known customer", req: abono.CheckoutRequest{Plan: "annual", Subject: "5", Email: "five@example.com"}, want: with("allow_promotion_codes", "true", "client_reference_id", "5", "subscription_data[metadata][abono_subject]", "5", "customer", "cus_known"), }, { name: "signed in, no customer yet, with a code", req: abono.CheckoutRequest{Plan: "annual", Subject: "6", Email: "six@example.com", PromotionCode: "INNOVATE"}, want: with("discounts[0][promotion_code]", "promo_1", "client_reference_id", "6", "subscription_data[metadata][abono_subject]", "6", "customer_email", "six@example.com"), }, } { t.Run(c.name, func(t *testing.T) { c.req.SuccessPath, c.req.CancelPath = "/join/done", "/join/annual" u, err := s.Checkout(ctx, c.req) if err != nil { t.Fatalf("Checkout: %v", err) } if u == "" { t.Error("Checkout returned no URL") } if got := d.lastCheckout(t); got.Encode() != c.want.Encode() { t.Errorf("form =\n%s\nwant\n%s", got.Encode(), c.want.Encode()) } }) } if _, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", PromotionCode: "NOPE"}); !errors.Is(err, abono.ErrUnknownCode) { t.Errorf("an unknown code: err = %v, want ErrUnknownCode", err) } if _, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "lifetime"}); !errors.Is(err, abono.ErrUnknownPlan) { t.Errorf("an unknown plan: err = %v, want ErrUnknownPlan", err) } } // TestCheckoutChoosesThePriceByInterval is rulings R15 and R17: Checkout // first learns the interval of any sold price it does not know yet, then // an interval picks the sold price with it, and no interval needs exactly // one sold price. func TestCheckoutChoosesThePriceByInterval(t *testing.T) { now := t0 d := newStripeDouble(t) s, db := d.service(t, &now, abono.Config{}) seedPlans(t, s) ctx := context.Background() req := func(plan, interval string) abono.CheckoutRequest { return abono.CheckoutRequest{Plan: plan, Interval: interval, SuccessPath: "/done", CancelPath: "/"} } // The plan sold yearly and monthly, neither interval known yet: a // second sold price recorded without a read, as a seed records one. d.price("price_A_month", "month", 600, "eur") db.exec(`INSERT INTO abono_prices (stripe_price_id, plan, sold) VALUES ('price_A_month', 'annual', 1)`) 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" { t.Fatalf("sold prices before Checkout = %q, want both intervals unknown", got) } if _, err := s.Checkout(ctx, req("annual", "year")); err != nil { t.Fatalf("interval year: %v", err) } if got := d.lastCheckout(t).Get("line_items[0][price]"); got != "price_A" { t.Errorf("year: price = %q, want price_A", got) } if got := d.fetches(); !reflect.DeepEqual(got, map[string]int{"price_A": 1, "price_A_month": 1}) { t.Errorf("price fetches = %v, want one each of the sold prices", got) } 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 != "price_A|year|6000|eur|1\nprice_A_month|month|600|eur|1\nprice_M||0||0\nprice_old||0||0\n" { t.Errorf("prices after Checkout =\n%s", got) } // Known now: no more reads. if _, err := s.Checkout(ctx, req("annual", "month")); err != nil { t.Fatalf("interval month: %v", err) } if got := d.lastCheckout(t).Get("line_items[0][price]"); got != "price_A_month" { t.Errorf("month: price = %q, want price_A_month", got) } if got := d.fetches(); !reflect.DeepEqual(got, map[string]int{"price_A": 1, "price_A_month": 1}) { t.Errorf("price fetches after a second Checkout = %v, want no more", got) } if _, err := s.Checkout(ctx, req("annual", "")); !errors.Is(err, abono.ErrUnknownPlan) { t.Errorf("two sold prices and no interval: err = %v, want ErrUnknownPlan", err) } if _, err := s.Checkout(ctx, req("annual", "week")); !errors.Is(err, abono.ErrUnknownPlan) { t.Errorf("an interval the plan is not sold at: err = %v, want ErrUnknownPlan", err) } // One sold price, no interval asked for: that one. if _, err := s.Checkout(ctx, req("monthly", "")); err != nil { t.Fatalf("one sold price: %v", err) } if got := d.lastCheckout(t).Get("line_items[0][price]"); got != "price_M" { t.Errorf("monthly: price = %q, want price_M", got) } // A legacy price is never sold. db.exec(`UPDATE abono_prices SET sold = 0 WHERE plan = 'monthly'`) if _, err := s.Checkout(ctx, req("monthly", "")); !errors.Is(err, abono.ErrUnknownPlan) { t.Errorf("a plan with no sold price: err = %v, want ErrUnknownPlan", err) } // A price with no interval at Stripe (a one-off) is read once, not on // every sale: refreshed_at, not the interval, says it is known (R19). d.price("price_once", "", 500, "eur") if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "once", Sold: "price_once"}}); err != nil { t.Fatalf("SeedPlans: %v", err) } for range 2 { if _, err := s.Checkout(ctx, req("once", "")); err != nil { t.Fatalf("one-off price: %v", err) } } if got := d.fetches()["price_once"]; got != 1 { t.Errorf("price_once read %d times, want once", got) } // A sold price Stripe no longer has stops the sale rather than guessing. db.exec(`INSERT INTO abono_prices (stripe_price_id, plan, sold) VALUES ('price_gone', 'annual', 1)`) if _, err := s.Checkout(ctx, req("annual", "year")); err == nil { t.Error("Checkout with a sold price Stripe cannot find succeeded") } } // TestCheckoutSuccessURL: session_id goes in the query, before any // fragment, replacing one already there, and Stripe's placeholder reaches // it with its braces. func TestCheckoutSuccessURL(t *testing.T) { now := t0 d := newStripeDouble(t) s, _ := d.service(t, &now, abono.Config{Origin: "https://site.example/"}) seedPlans(t, s) ctx := context.Background() for path, want := range map[string]string{ "": "https://site.example/?session_id={CHECKOUT_SESSION_ID}", "/done": "https://site.example/done?session_id={CHECKOUT_SESSION_ID}", "/done#receipt": "https://site.example/done?session_id={CHECKOUT_SESSION_ID}#receipt", "/done?session_id=old&x=1": "https://site.example/done?x=1&session_id={CHECKOUT_SESSION_ID}", "/done?a=1&session_id=old#f": "https://site.example/done?a=1&session_id={CHECKOUT_SESSION_ID}#f", } { if _, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", SuccessPath: path}); err != nil { t.Fatalf("SuccessPath %q: %v", path, err) } if got := d.lastCheckout(t).Get("success_url"); got != want { t.Errorf("SuccessPath %q: success_url = %q, want %q", path, got, want) } } for _, bad := range []string{"done", "//evil.example/x", "https://evil.example/"} { if _, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", SuccessPath: bad}); err == nil { t.Errorf("SuccessPath %q accepted", bad) } } } // TestPortalIsPerCustomer is final review I4 and I5 (ruling R37). A // reader may pay through several Stripe customers (two guest purchases, an // imported member), and a subscription on a price no plan has is still // charged. Customers lists every customer of the subject's subscription // rows, recognised or not, and Portal opens any of them, and only them. func TestPortalIsPerCustomer(t *testing.T) { now := t0 d := newStripeDouble(t) s, _ := d.service(t, &now, abono.Config{Origin: "https://site.example"}) seedPlans(t, s) ctx := context.Background() if _, err := s.Portal(ctx, "5", "cus_a", "/account"); !errors.Is(err, abono.ErrNoCustomer) { t.Errorf("Portal with no subscription: err = %v, want ErrNoCustomer", err) } if cs, err := s.Customers(ctx, "5"); err != nil || len(cs) != 0 { t.Errorf("Customers with no subscription = %q, %v", cs, err) } // An older customer still paying, a newer one whose subscription has // ended, a third on a price no plan has, and someone else's. sync := func(sp subSpec, subject string) { t.Helper() if err := s.Sync(ctx, d.put(t, sp), abono.Hint{Subject: subject}); err != nil { t.Fatalf("Sync %s: %v", sp.ID, err) } now = now.Add(time.Minute) } sync(subSpec{ID: "sub_a", Status: "active", Price: "price_A", Customer: "cus_a", Email: "five@example.com", End: t0.Add(300 * day)}, "5") sync(subSpec{ID: "sub_b", Status: "active", Price: "price_M", Customer: "cus_b", Email: "five@example.com", End: t0.Add(30 * day)}, "5") sync(subSpec{ID: "sub_b", Status: "canceled", Price: "price_M", Customer: "cus_b", Email: "five@example.com", End: t0.Add(30 * day)}, "5") sync(subSpec{ID: "sub_c", Status: "active", Price: "price_Z", Customer: "cus_c", Email: "five@example.com", End: t0.Add(30 * day)}, "5") sync(subSpec{ID: "sub_x", Status: "active", Price: "price_A", Customer: "cus_x", Email: "six@example.com", End: t0.Add(30 * day)}, "6") cs, err := s.Customers(ctx, "5") if err != nil { t.Fatalf("Customers: %v", err) } if want := []string{"cus_c", "cus_b", "cus_a"}; !reflect.DeepEqual(cs, want) { t.Errorf("Customers(5) = %q, want %q (newest subscription first)", cs, want) } for _, c := range []string{"cus_a", "cus_b", "cus_c"} { u, err := s.Portal(ctx, "5", c, "/account") if err != nil { t.Fatalf("Portal(5, %s): %v", c, err) } if u != d.URL+"/portal/"+c { t.Errorf("Portal(5, %s) = %q", c, u) } forms := d.PortalForms() if got := forms[len(forms)-1]; got.Get("customer") != c || got.Get("return_url") != "https://site.example/account" { t.Errorf("portal form = %v", got) } } for _, c := range []string{"cus_x", "", "cus_nobody"} { if _, err := s.Portal(ctx, "5", c, "/account"); !errors.Is(err, abono.ErrNoCustomer) { t.Errorf("Portal(5, %q): err = %v, want ErrNoCustomer", c, err) } } if _, err := s.Portal(ctx, "", "cus_a", "/account"); !errors.Is(err, abono.ErrNoCustomer) { t.Errorf("Portal for nobody: err = %v, want ErrNoCustomer", err) } }