package main // Pricing bands, Phase 0 (docs/pricing-bands.md): the price book's honor-band // dimension, the checkout band param, Stripe Tax plumbing, and the ledger's // band column. The invariant under test throughout: whatever band arrives is // honoured — priced exactly as the book says, recorded exactly as bought, // never silently substituted. import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" ) // withBands gives the harness book a band dimension: friends gains reduced, // business gains reduced + the business flag. family stays band-less to pin // backward compatibility. func withBands(s *cloudServer) { for i, t := range s.billing.tiers { switch t.Key { case "friends": s.billing.tiers[i].EUR = 4 s.billing.tiers[i].NetEURYear = 44 s.billing.tiers[i].Bands = map[string]billingBand{ "reduced": {Label: "€2/month", Price: "price_friends_m_red", EUR: 2, NetEURYear: 21}, } case "business": s.billing.tiers[i].Business = true s.billing.tiers[i].Bands = map[string]billingBand{ "reduced": {Label: "€49/month", Price: "price_business_m_red", EUR: 49}, } } } } // postCheckoutBand is postCheckout with an explicit band. func postCheckoutBand(t *testing.T, srv *httptest.Server, token, host, requester, tier, band string) *http.Response { t.Helper() body, _ := json.Marshal(map[string]string{ "requester": requester, "tier": tier, "band": band, "success_url": "https://home.example.com/?upgraded=" + host, "cancel_url": "https://home.example.com/", }) req, _ := http.NewRequest("POST", srv.URL+"/api/instances/"+host+"/checkout", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } t.Cleanup(func() { resp.Body.Close() }) return resp } func spaceBandTier(t *testing.T, s *cloudServer, id int64) (tier, band string) { t.Helper() if err := s.db.sql.QueryRow(`SELECT tier, band FROM spaces WHERE id = ?`, id).Scan(&tier, &band); err != nil { t.Fatal(err) } return tier, band } func TestPriceBookBandValidation(t *testing.T) { mk := func(book string) error { _, err := newStripeBilling("sk", "whsec", book, "https://api.example.com") return err } // A banded book parses; a band-less one still does (backward compat). if err := mk(`[{"key":"family","name":"Family","label":"€5/year","price":"p1", "bands":{"reduced":{"label":"€2.50/year","price":"p2"},"supporter":{"label":"€7/year","price":"p3"},"solidarity":{"label":"€1.25/year","price":"p4"}}}]`); err != nil { t.Fatalf("full band set: %v", err) } if err := mk(`[{"key":"family","name":"Family","label":"€5/year","price":"p1"}]`); err != nil { t.Fatalf("band-less book: %v", err) } // Unknown band names, base-as-a-band, incomplete bands, and any duplicated // price id are config mistakes that must refuse to boot. for name, book := range map[string]string{ "unknown band": `[{"key":"f","name":"F","label":"l","price":"p1","bands":{"student":{"label":"l","price":"p2"}}}]`, "base as band": `[{"key":"f","name":"F","label":"l","price":"p1","bands":{"base":{"label":"l","price":"p2"}}}]`, "missing price": `[{"key":"f","name":"F","label":"l","price":"p1","bands":{"reduced":{"label":"l"}}}]`, "dup price across bands": `[{"key":"f","name":"F","label":"l","price":"p1","bands":{"reduced":{"label":"l","price":"p1"}}}]`, "dup price across tiers": `[{"key":"f","name":"F","label":"l","price":"p1"},{"key":"g","name":"G","label":"l","price":"p1"}]`, } { if err := mk(book); err == nil { t.Errorf("%s: parsed, want error", name) } } } func TestTierBandByPrice(t *testing.T) { _, s, _ := billingTestCloud(t) withBands(s) if tier, band, ok := s.billing.tierBandByPrice("price_friends_m"); !ok || tier.Key != "friends" || band != "base" { t.Fatalf("base price → %s/%s ok=%v", tier.Key, band, ok) } if tier, band, ok := s.billing.tierBandByPrice("price_friends_m_red"); !ok || tier.Key != "friends" || band != "reduced" { t.Fatalf("band price → %s/%s ok=%v", tier.Key, band, ok) } if _, _, ok := s.billing.tierBandByPrice("price_unknown"); ok { t.Fatal("unknown price resolved") } } func TestCheckoutBand(t *testing.T) { srv, s, captured := billingTestCloud(t) withBands(s) tok := mintCaller(t, srv, "home") host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) // A band checkout charges the band's own Price and stamps band metadata in // both routing slots. if resp := postCheckoutBand(t, srv, tok, host, "user-1", "friends", "reduced"); resp.StatusCode != http.StatusOK { t.Fatalf("reduced checkout: %d", resp.StatusCode) } f := *captured if f.Get("line_items[0][price]") != "price_friends_m_red" { t.Fatalf("price = %q, want the reduced band's", f.Get("line_items[0][price]")) } if f.Get("metadata[band]") != "reduced" || f.Get("subscription_data[metadata][band]") != "reduced" { t.Fatalf("band metadata: session=%q sub=%q", f.Get("metadata[band]"), f.Get("subscription_data[metadata][band]")) } // Stripe Tax is an operator knob — absent until flipped. if f.Has("automatic_tax[enabled]") || f.Has("tax_id_collection[enabled]") { t.Fatalf("tax fields sent with the knob off: %v", f) } // No band = base, priced and stamped as such. if resp := postCheckout(t, srv, tok, host, "user-1", "friends"); resp.StatusCode != http.StatusOK { t.Fatalf("base checkout: %d", resp.StatusCode) } f = *captured if f.Get("line_items[0][price]") != "price_friends_m" || f.Get("metadata[band]") != "base" { t.Fatalf("base checkout form: price=%q band=%q", f.Get("line_items[0][price]"), f.Get("metadata[band]")) } // A band this tier doesn't offer is refused — never silently re-priced. if resp := postCheckoutBand(t, srv, tok, host, "user-1", "friends", "solidarity"); resp.StatusCode != http.StatusBadRequest { t.Fatalf("unoffered band: %d, want 400", resp.StatusCode) } // A band on a band-less tier likewise (family has none). if resp := postCheckoutBand(t, srv, tok, host, "user-1", "family", "reduced"); resp.StatusCode != http.StatusBadRequest { t.Fatalf("band on band-less tier: %d, want 400", resp.StatusCode) } } func TestCheckoutStripeTax(t *testing.T) { srv, s, captured := billingTestCloud(t) withBands(s) s.billing.automaticTax = true tok := mintCaller(t, srv, "home") host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) // Consumer tier: tax on, no VAT-ID collection. if resp := postCheckout(t, srv, tok, host, "user-1", "friends"); resp.StatusCode != http.StatusOK { t.Fatalf("friends checkout: %d", resp.StatusCode) } f := *captured if f.Get("automatic_tax[enabled]") != "true" || f.Has("tax_id_collection[enabled]") { t.Fatalf("consumer tax fields: %v", f) } // Business tier: tax on AND the VAT ID field (EU reverse charge). if resp := postCheckout(t, srv, tok, host, "user-1", "business"); resp.StatusCode != http.StatusOK { t.Fatalf("business checkout: %d", resp.StatusCode) } f = *captured if f.Get("automatic_tax[enabled]") != "true" || f.Get("tax_id_collection[enabled]") != "true" { t.Fatalf("business tax fields: %v", f) } } func TestWebhookRecordsBand(t *testing.T) { srv, s, _ := billingTestCloud(t) withBands(s) tok := mintCaller(t, srv, "home") createSpace(t, srv, tok, "user-1") deliver := func(event string) { t.Helper() payload := []byte(event) if r := postWebhook(t, srv, payload, stripeSign(payload, "whsec_test_secret", time.Now())); r.StatusCode != http.StatusOK { t.Fatalf("webhook: %d", r.StatusCode) } } // Checkout completion seeds tier + band from the session metadata. deliver(`{"type":"checkout.session.completed","data":{"object":{ "mode":"subscription","client_reference_id":"1","customer":"cus_1","subscription":"sub_1", "metadata":{"tier":"friends","band":"reduced"}}}}`) if tier, band := spaceBandTier(t, s, 1); tier != "friends" || band != "reduced" { t.Fatalf("after checkout: %s/%s, want friends/reduced", tier, band) } // A plan switch to another band's price moves the recorded band — the // subscription's PRICE is the truth, metadata only seeded the first value. deliver(`{"type":"customer.subscription.updated","data":{"object":{ "id":"sub_1","status":"active","metadata":{"space_id":"1","tier":"friends","band":"reduced"}, "items":{"data":[{"current_period_end":1900000000,"price":{"id":"price_friends_m"}}]}}}}`) if tier, band := spaceBandTier(t, s, 1); tier != "friends" || band != "base" { t.Fatalf("after switch to base price: %s/%s, want friends/base", tier, band) } // An event without price data keeps the recorded band (COALESCE semantics). deliver(`{"type":"customer.subscription.updated","data":{"object":{ "id":"sub_1","status":"active","metadata":{"space_id":"1"},"current_period_end":1900000099}}}`) if _, band := spaceBandTier(t, s, 1); band != "base" { t.Fatalf("band lost on a price-less event: %q", band) } } func TestPortalBandConfig(t *testing.T) { // A portal fake capturing the configuration param. captured := url.Values{} stripe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/billing_portal/sessions" { w.WriteHeader(http.StatusNotFound) return } _ = r.ParseForm() captured = r.PostForm writeJSON(w, http.StatusOK, map[string]any{"url": "https://billing.stripe.com/p/session_x"}) })) defer stripe.Close() srv, s, _ := billingTestCloud(t) withBands(s) s.billing.apiBase = stripe.URL s.billing.portalConfig = "bpc_default" s.billing.portalConfigs = map[string]string{"reduced": "bpc_reduced"} tok := mintCaller(t, srv, "home") host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) if err := s.db.markSpacePaid(1, "cus_1", "sub_1", "friends", "reduced"); err != nil { t.Fatal(err) } postPortal := func() { t.Helper() body, _ := json.Marshal(map[string]string{"requester": "user-1", "return_url": "https://home.example.com/"}) req, _ := http.NewRequest("POST", srv.URL+"/api/instances/"+host+"/portal", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+tok) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("portal: %d", resp.StatusCode) } } // The reduced-band space gets the reduced portal configuration… postPortal() if got := captured.Get("configuration"); got != "bpc_reduced" { t.Fatalf("configuration = %q, want bpc_reduced", got) } // …and a band without its own config falls back to the default. if _, err := s.db.sql.Exec(`UPDATE spaces SET band = 'supporter' WHERE id = 1`); err != nil { t.Fatal(err) } postPortal() if got := captured.Get("configuration"); got != "bpc_default" { t.Fatalf("fallback configuration = %q, want bpc_default", got) } } func TestPublicTiersBands(t *testing.T) { _, s, _ := billingTestCloud(t) withBands(s) out := s.billing.publicTiers() var friends map[string]any for _, e := range out { if e["key"] == "friends" { friends = e } } if friends == nil { t.Fatal("friends tier missing from listing") } if friends["eur"] != 4.0 { t.Fatalf("eur = %v", friends["eur"]) } bands, _ := friends["bands"].(map[string]any) red, _ := bands["reduced"].(map[string]any) if red == nil || red["label"] != "€2/month" || red["eur"] != 2.0 { t.Fatalf("reduced band listing = %v", red) } // Price ids never cross the public boundary. blob, _ := json.Marshal(out) if strings.Contains(string(blob), "price_") { t.Fatalf("listing leaks a price id: %s", blob) } } func TestNetForTierBand(t *testing.T) { _, s, _ := billingTestCloud(t) withBands(s) if net := s.netForTier("friends", "base", 0); net != 44 { t.Fatalf("base net = %v", net) } if net := s.netForTier("friends", "", 0); net != 44 { t.Fatalf("empty-band net = %v (pre-band rows read as base)", net) } if net := s.netForTier("friends", "reduced", 0); net != 21 { t.Fatalf("reduced net = %v", net) } // A recorded band the book no longer names falls back to base, not zero. if net := s.netForTier("friends", "supporter", 0); net != 44 { t.Fatalf("retired-band net = %v", net) } }