package abono_test import ( "context" "errors" "reflect" "strings" "testing" "amadan.net/rastrillo/abono" ) func TestSeedPlansIsIdempotent(t *testing.T) { now := t0 s, db := newService(t, &now, abono.Config{}) ctx := context.Background() spec := []abono.PlanSpec{ {Plan: "annual", Sold: "price_A", Legacy: []string{"price_old"}}, {Plan: "monthly", Sold: "price_B"}, } if err := s.SeedPlans(ctx, spec); err != nil { t.Fatalf("SeedPlans: %v", err) } first := db.dump("SELECT plan, name FROM abono_offers ORDER BY plan") + db.dump("SELECT stripe_price_id, plan, sold FROM abono_prices ORDER BY stripe_price_id") if err := s.SeedPlans(ctx, spec); err != nil { t.Fatalf("second SeedPlans: %v", err) } second := db.dump("SELECT plan, name FROM abono_offers ORDER BY plan") + db.dump("SELECT stripe_price_id, plan, sold FROM abono_prices ORDER BY stripe_price_id") if first != second { t.Errorf("a second SeedPlans changed the rows:\nfirst:\n%s\nsecond:\n%s", first, second) } want := "annual|\nmonthly|\n" + "price_A|annual|1\nprice_B|monthly|1\nprice_old|annual|0\n" if first != want { t.Errorf("rows =\n%s\nwant\n%s", first, want) } // A price removed from the spec stays: existing subscriptions on it // must still resolve to their plan. if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_A"}, {Plan: "monthly", Sold: "price_B"}}); err != nil { t.Fatalf("SeedPlans without the legacy price: %v", err) } if got := db.dump("SELECT stripe_price_id FROM abono_prices WHERE stripe_price_id = 'price_old'"); got != "price_old\n" { t.Errorf("price_old was dropped from the table: %q", got) } // A new sold price takes over from the old one named in the spec. if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_C", Legacy: []string{"price_A"}}}); err != nil { t.Fatalf("SeedPlans with a new sold price: %v", err) } if got := db.dump("SELECT stripe_price_id, sold FROM abono_prices WHERE plan = 'annual' ORDER BY stripe_price_id"); got != "price_A|0\nprice_C|1\nprice_old|0\n" { t.Errorf("annual prices = %q", got) } } func TestSeedPlansRefusesBadSpecs(t *testing.T) { now := t0 s, db := newService(t, &now, abono.Config{}) for name, spec := range map[string][]abono.PlanSpec{ "no plan name": {{Sold: "price_A"}}, "no sold price": {{Plan: "annual"}}, "a blank legacy price": {{Plan: "annual", Sold: "price_A", Legacy: []string{""}}}, "a price in two plans": {{Plan: "annual", Sold: "price_A"}, {Plan: "monthly", Sold: "price_B", Legacy: []string{"price_A"}}}, "a plan twice": {{Plan: "annual", Sold: "price_A"}, {Plan: "annual", Sold: "price_B"}}, "a price twice in a plan": {{Plan: "annual", Sold: "price_A", Legacy: []string{"price_A"}}}, } { if err := s.SeedPlans(context.Background(), spec); err == nil { t.Errorf("%s: SeedPlans succeeded, want an error", name) } } if got := db.dump("SELECT plan FROM abono_offers"); got != "" { t.Errorf("a refused spec left offers behind: %q", got) } } func TestOffersAndAddPrice(t *testing.T) { now := t0 s, db := newService(t, &now, abono.Config{}) ctx := context.Background() if err := s.SeedPlans(ctx, []abono.PlanSpec{ {Plan: "annual", Sold: "price_A", Legacy: []string{"price_old"}}, {Plan: "monthly", Sold: "price_B"}, }); err != nil { t.Fatal(err) } // What a later refresh from Stripe would record. db.exec("UPDATE abono_prices SET interval = 'year', amount = 4900, currency = 'eur' WHERE stripe_price_id = 'price_A'") db.exec("UPDATE abono_offers SET name = 'Annual membership' WHERE plan = 'annual'") offers, err := s.Offers(ctx) if err != nil { t.Fatalf("Offers: %v", err) } if len(offers) != 2 || offers[0].Plan != "annual" || offers[1].Plan != "monthly" { t.Fatalf("offers = %+v", offers) } if offers[0].Name != "Annual membership" { t.Errorf("annual Name = %q", offers[0].Name) } if offers[1].Name != "monthly" { t.Errorf("monthly Name = %q, want it to default to the plan", offers[1].Name) } annual := offers[0].Prices if len(annual) != 2 || annual[0] != (abono.Price{ID: "price_A", Sold: true, Interval: "year", Amount: 4900, Currency: "eur"}) || annual[1].ID != "price_old" || annual[1].Sold { t.Errorf("annual prices = %+v, want the sold price first", annual) } // Selling a price reads it from Stripe first (ruling R38), so with // Stripe off only a legacy price can be added. if err := s.AddPrice(ctx, "monthly", "price_B2", true); err == nil { t.Fatal("AddPrice of a sold price succeeded with Stripe off") } if err := s.AddPrice(ctx, "monthly", "price_B2", false); err != nil { t.Fatalf("AddPrice: %v", err) } if err := s.AddPrice(ctx, "nope", "price_X", false); err == nil { t.Error("AddPrice to an unknown plan succeeded") } if err := s.AddPrice(ctx, "annual", "price_B", false); err == nil { t.Error("AddPrice moved a price that belongs to another plan") } offers, _ = s.Offers(ctx) if got := offers[1].Prices; len(got) != 2 || got[0].ID != "price_B" || got[1].ID != "price_B2" || got[1].Sold { t.Errorf("monthly prices = %+v", got) } // An archived offer is not on offer. db.exec("UPDATE abono_offers SET archived_at = '2026-10-01 00:00:00' WHERE plan = 'monthly'") offers, _ = s.Offers(ctx) if len(offers) != 1 || offers[0].Plan != "annual" { t.Errorf("offers after archiving = %+v", offers) } } func TestSeedPlansDemotesTheOldSoldPrice(t *testing.T) { now := t0 s, db := newService(t, &now, abono.Config{}) ctx := context.Background() if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_A"}, {Plan: "monthly", Sold: "price_M"}}); err != nil { t.Fatal(err) } if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_B", Legacy: []string{"price_A"}}}); err != nil { t.Fatal(err) } got := db.dump("SELECT stripe_price_id, plan, sold FROM abono_prices ORDER BY stripe_price_id") if want := "price_A|annual|0\nprice_B|annual|1\nprice_M|monthly|1\n"; got != want { t.Errorf("prices =\n%s\nwant\n%s", got, want) } // A price the spec no longer names is demoted too, and still not deleted. if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_C"}}); err != nil { t.Fatal(err) } got = db.dump("SELECT stripe_price_id, sold FROM abono_prices WHERE plan = 'annual' ORDER BY stripe_price_id") if want := "price_A|0\nprice_B|0\nprice_C|1\n"; got != want { t.Errorf("annual prices =\n%s\nwant\n%s", got, want) } } // TestAnUnsupportedPriceIsNeverSold is final review I7 (ruling R39): a // price of €120 every twelve months has Stripe's interval "month". abono // keeps its interval_count, never sells it, and says so on Offers. func TestAnUnsupportedPriceIsNeverSold(t *testing.T) { now := t0 d := newStripeDouble(t) s, db := d.service(t, &now, abono.Config{}) ctx := context.Background() d.price("price_12m", "month", 12000, "eur") d.SetIntervalCount("price_12m", 12) if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "dozen", Sold: "price_12m"}}); err != nil { t.Fatalf("SeedPlans: %v", err) } for _, iv := range []string{"month", ""} { _, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "dozen", Interval: iv, SuccessPath: "/done"}) if !errors.Is(err, abono.ErrUnknownPlan) { t.Errorf("Checkout(dozen, %q): err = %v, want ErrUnknownPlan", iv, err) } } if len(d.CheckoutForms()) != 0 { t.Error("a Checkout session was made for an unsupported price") } if got := db.dump(`SELECT interval, interval_count FROM abono_prices WHERE stripe_price_id = 'price_12m'`); got != "month|12\n" { t.Errorf("price_12m = %q, want month|12", got) } offers, err := s.Offers(ctx) if err != nil || len(offers) != 1 || len(offers[0].Prices) != 1 { t.Fatalf("Offers = %+v, %v", offers, err) } if p := offers[0].Prices[0]; p.IntervalCount != 12 || !p.Unsupported() { t.Errorf("Offers price = %+v, want every 12 months and unsupported", p) } } // TestAddPriceKeepsOneSoldPricePerInterval is final review I6 (ruling // R38): adding a replacement sold price for a plan and interval demotes // the one it replaces, in the same transaction, so Checkout still finds // exactly one to sell. A sold price at another interval stays sold. func TestAddPriceKeepsOneSoldPricePerInterval(t *testing.T) { now := t0 d := newStripeDouble(t) s, db := d.service(t, &now, abono.Config{}) seedPlans(t, s) // annual: price_A (year), legacy price_old; monthly: price_M ctx := context.Background() sold := func() string { t.Helper() return db.dump(`SELECT stripe_price_id FROM abono_prices WHERE plan = 'annual' AND sold = 1 ORDER BY stripe_price_id`) } d.price("price_A2", "year", 7000, "eur") if err := s.AddPrice(ctx, "annual", "price_A2", true); err != nil { t.Fatalf("AddPrice: %v", err) } if got := sold(); got != "price_A2\n" { t.Errorf("sold annual prices = %q, want only the replacement", got) } if got := db.dump(`SELECT interval, interval_count, amount, currency, refreshed_at IS NOT NULL FROM abono_prices WHERE stripe_price_id = 'price_A2'`); got != "year|1|7000|eur|1\n" { t.Errorf("price_A2 as recorded = %q, want what Stripe said", got) } if _, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", Interval: "year", SuccessPath: "/done"}); err != nil { t.Fatalf("Checkout at the replaced interval: %v", err) } if got := d.lastCheckout(t).Get("line_items[0][price]"); got != "price_A2" { t.Errorf("Checkout sold %q, want price_A2", got) } // Another interval is another sold price, not a replacement. d.price("price_A_month", "month", 700, "eur") if err := s.AddPrice(ctx, "annual", "price_A_month", true); err != nil { t.Fatalf("AddPrice monthly: %v", err) } if got := sold(); got != "price_A2\nprice_A_month\n" { t.Errorf("sold annual prices = %q, want the yearly and the monthly", got) } // A price Stripe does not have changes nothing. if err := s.AddPrice(ctx, "annual", "price_nowhere", true); err == nil { t.Error("AddPrice of a price Stripe does not have succeeded") } if got := sold(); got != "price_A2\nprice_A_month\n" { t.Errorf("after a failed AddPrice, sold annual prices = %q", got) } if got := db.dump(`SELECT count(*) FROM abono_prices WHERE stripe_price_id = 'price_nowhere'`); got != "0\n" { t.Error("a price Stripe does not have was recorded") } } // TestOffersReadsPricesNeverRead is final review I12 (ruling R42): a // freshly seeded site has never read its prices from Stripe, and nothing // in the documented wiring makes it reconcile first. Offers reads, once // and best-effort, every price never read, so the pricing UI knows each // interval before anyone buys. A price Stripe cannot read is left unknown // and does not fail the page. func TestOffersReadsPricesNeverRead(t *testing.T) { now := t0 d := newStripeDouble(t) s, db := d.service(t, &now, abono.Config{}) seedPlans(t, s) // annual: price_A (year) sold, price_old legacy; monthly: price_M ctx := context.Background() // annual is sold monthly too, recorded by id without a read, and a // legacy price Stripe no longer has. 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), ('price_lost', 'annual', 0)`) offers, err := s.Offers(ctx) if err != nil { t.Fatalf("Offers: %v", err) } intervals := map[string]string{} for _, o := range offers { for _, p := range o.Prices { intervals[p.ID] = p.Interval } } if want := map[string]string{"price_A": "year", "price_A_month": "month", "price_old": "year", "price_M": "month", "price_lost": ""}; !reflect.DeepEqual(intervals, want) { t.Errorf("intervals = %v, want %v", intervals, want) } l := testLocalizer(t, testCatalog) picker := render(t, "abono-plan-picker", map[string]any{"L": l, "Offers": offers, "Action": "/subscribe"}) for _, want := range []string{"T-MONTHLY", "T-YEARLY", `value="month"`, `value="year"`} { if !strings.Contains(picker, want) { t.Errorf("the picker on a fresh site lacks %q:\n%s", want, picker) } } // Read once: a second Offers asks Stripe nothing more (the lost // price aside, which is tried again). before := d.fetches() if _, err := s.Offers(ctx); err != nil { t.Fatalf("Offers again: %v", err) } after := d.fetches() for id, n := range after { if id != "price_lost" && n != before[id] { t.Errorf("%s read again: %d times, was %d", id, n, before[id]) } } // With Stripe off nothing is read, and Offers still answers. off, _ := newService(t, &now, abono.Config{}) if err := off.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_A"}}); err != nil { t.Fatal(err) } if offers, err := off.Offers(ctx); err != nil || len(offers) != 1 || offers[0].Prices[0].Interval != "" { t.Errorf("Offers with Stripe off = %+v, %v", offers, err) } }