eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// Pricing bands, Phase 0 (docs/pricing-bands.md): the price book's honor-band
4// dimension, the checkout band param, Stripe Tax plumbing, and the ledger's
5// band column. The invariant under test throughout: whatever band arrives is
6// honoured — priced exactly as the book says, recorded exactly as bought,
7// never silently substituted.
8
9import (
10 "bytes"
11 "encoding/json"
12 "net/http"
13 "net/http/httptest"
14 "net/url"
15 "strings"
16 "testing"
17 "time"
18)
19
20// withBands gives the harness book a band dimension: friends gains reduced,
21// business gains reduced + the business flag. family stays band-less to pin
22// backward compatibility.
23func withBands(s *cloudServer) {
24 for i, t := range s.billing.tiers {
25 switch t.Key {
26 case "friends":
27 s.billing.tiers[i].EUR = 4
28 s.billing.tiers[i].NetEURYear = 44
29 s.billing.tiers[i].Bands = map[string]billingBand{
30 "reduced": {Label: "€2/month", Price: "price_friends_m_red", EUR: 2, NetEURYear: 21},
31 }
32 case "business":
33 s.billing.tiers[i].Business = true
34 s.billing.tiers[i].Bands = map[string]billingBand{
35 "reduced": {Label: "€49/month", Price: "price_business_m_red", EUR: 49},
36 }
37 }
38 }
39}
40
41// postCheckoutBand is postCheckout with an explicit band.
42func postCheckoutBand(t *testing.T, srv *httptest.Server, token, host, requester, tier, band string) *http.Response {
43 t.Helper()
44 body, _ := json.Marshal(map[string]string{
45 "requester": requester,
46 "tier": tier,
47 "band": band,
48 "success_url": "https://home.example.com/?upgraded=" + host,
49 "cancel_url": "https://home.example.com/",
50 })
51 req, _ := http.NewRequest("POST", srv.URL+"/api/instances/"+host+"/checkout", bytes.NewReader(body))
52 req.Header.Set("Authorization", "Bearer "+token)
53 resp, err := http.DefaultClient.Do(req)
54 if err != nil {
55 t.Fatal(err)
56 }
57 t.Cleanup(func() { resp.Body.Close() })
58 return resp
59}
60
61func spaceBandTier(t *testing.T, s *cloudServer, id int64) (tier, band string) {
62 t.Helper()
63 if err := s.db.sql.QueryRow(`SELECT tier, band FROM spaces WHERE id = ?`, id).Scan(&tier, &band); err != nil {
64 t.Fatal(err)
65 }
66 return tier, band
67}
68
69func TestPriceBookBandValidation(t *testing.T) {
70 mk := func(book string) error {
71 _, err := newStripeBilling("sk", "whsec", book, "https://api.example.com")
72 return err
73 }
74 // A banded book parses; a band-less one still does (backward compat).
75 if err := mk(`[{"key":"family","name":"Family","label":"€5/year","price":"p1",
76 "bands":{"reduced":{"label":"€2.50/year","price":"p2"},"supporter":{"label":"€7/year","price":"p3"},"solidarity":{"label":"€1.25/year","price":"p4"}}}]`); err != nil {
77 t.Fatalf("full band set: %v", err)
78 }
79 if err := mk(`[{"key":"family","name":"Family","label":"€5/year","price":"p1"}]`); err != nil {
80 t.Fatalf("band-less book: %v", err)
81 }
82 // Unknown band names, base-as-a-band, incomplete bands, and any duplicated
83 // price id are config mistakes that must refuse to boot.
84 for name, book := range map[string]string{
85 "unknown band": `[{"key":"f","name":"F","label":"l","price":"p1","bands":{"student":{"label":"l","price":"p2"}}}]`,
86 "base as band": `[{"key":"f","name":"F","label":"l","price":"p1","bands":{"base":{"label":"l","price":"p2"}}}]`,
87 "missing price": `[{"key":"f","name":"F","label":"l","price":"p1","bands":{"reduced":{"label":"l"}}}]`,
88 "dup price across bands": `[{"key":"f","name":"F","label":"l","price":"p1","bands":{"reduced":{"label":"l","price":"p1"}}}]`,
89 "dup price across tiers": `[{"key":"f","name":"F","label":"l","price":"p1"},{"key":"g","name":"G","label":"l","price":"p1"}]`,
90 } {
91 if err := mk(book); err == nil {
92 t.Errorf("%s: parsed, want error", name)
93 }
94 }
95}
96
97func TestTierBandByPrice(t *testing.T) {
98 _, s, _ := billingTestCloud(t)
99 withBands(s)
100 if tier, band, ok := s.billing.tierBandByPrice("price_friends_m"); !ok || tier.Key != "friends" || band != "base" {
101 t.Fatalf("base price → %s/%s ok=%v", tier.Key, band, ok)
102 }
103 if tier, band, ok := s.billing.tierBandByPrice("price_friends_m_red"); !ok || tier.Key != "friends" || band != "reduced" {
104 t.Fatalf("band price → %s/%s ok=%v", tier.Key, band, ok)
105 }
106 if _, _, ok := s.billing.tierBandByPrice("price_unknown"); ok {
107 t.Fatal("unknown price resolved")
108 }
109}
110
111func TestCheckoutBand(t *testing.T) {
112 srv, s, captured := billingTestCloud(t)
113 withBands(s)
114 tok := mintCaller(t, srv, "home")
115 host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string)
116
117 // A band checkout charges the band's own Price and stamps band metadata in
118 // both routing slots.
119 if resp := postCheckoutBand(t, srv, tok, host, "user-1", "friends", "reduced"); resp.StatusCode != http.StatusOK {
120 t.Fatalf("reduced checkout: %d", resp.StatusCode)
121 }
122 f := *captured
123 if f.Get("line_items[0][price]") != "price_friends_m_red" {
124 t.Fatalf("price = %q, want the reduced band's", f.Get("line_items[0][price]"))
125 }
126 if f.Get("metadata[band]") != "reduced" || f.Get("subscription_data[metadata][band]") != "reduced" {
127 t.Fatalf("band metadata: session=%q sub=%q", f.Get("metadata[band]"), f.Get("subscription_data[metadata][band]"))
128 }
129 // Stripe Tax is an operator knob — absent until flipped.
130 if f.Has("automatic_tax[enabled]") || f.Has("tax_id_collection[enabled]") {
131 t.Fatalf("tax fields sent with the knob off: %v", f)
132 }
133
134 // No band = base, priced and stamped as such.
135 if resp := postCheckout(t, srv, tok, host, "user-1", "friends"); resp.StatusCode != http.StatusOK {
136 t.Fatalf("base checkout: %d", resp.StatusCode)
137 }
138 f = *captured
139 if f.Get("line_items[0][price]") != "price_friends_m" || f.Get("metadata[band]") != "base" {
140 t.Fatalf("base checkout form: price=%q band=%q", f.Get("line_items[0][price]"), f.Get("metadata[band]"))
141 }
142
143 // A band this tier doesn't offer is refused — never silently re-priced.
144 if resp := postCheckoutBand(t, srv, tok, host, "user-1", "friends", "solidarity"); resp.StatusCode != http.StatusBadRequest {
145 t.Fatalf("unoffered band: %d, want 400", resp.StatusCode)
146 }
147 // A band on a band-less tier likewise (family has none).
148 if resp := postCheckoutBand(t, srv, tok, host, "user-1", "family", "reduced"); resp.StatusCode != http.StatusBadRequest {
149 t.Fatalf("band on band-less tier: %d, want 400", resp.StatusCode)
150 }
151}
152
153func TestCheckoutStripeTax(t *testing.T) {
154 srv, s, captured := billingTestCloud(t)
155 withBands(s)
156 s.billing.automaticTax = true
157 tok := mintCaller(t, srv, "home")
158 host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string)
159
160 // Consumer tier: tax on, no VAT-ID collection.
161 if resp := postCheckout(t, srv, tok, host, "user-1", "friends"); resp.StatusCode != http.StatusOK {
162 t.Fatalf("friends checkout: %d", resp.StatusCode)
163 }
164 f := *captured
165 if f.Get("automatic_tax[enabled]") != "true" || f.Has("tax_id_collection[enabled]") {
166 t.Fatalf("consumer tax fields: %v", f)
167 }
168 // Business tier: tax on AND the VAT ID field (EU reverse charge).
169 if resp := postCheckout(t, srv, tok, host, "user-1", "business"); resp.StatusCode != http.StatusOK {
170 t.Fatalf("business checkout: %d", resp.StatusCode)
171 }
172 f = *captured
173 if f.Get("automatic_tax[enabled]") != "true" || f.Get("tax_id_collection[enabled]") != "true" {
174 t.Fatalf("business tax fields: %v", f)
175 }
176}
177
178func TestWebhookRecordsBand(t *testing.T) {
179 srv, s, _ := billingTestCloud(t)
180 withBands(s)
181 tok := mintCaller(t, srv, "home")
182 createSpace(t, srv, tok, "user-1")
183
184 deliver := func(event string) {
185 t.Helper()
186 payload := []byte(event)
187 if r := postWebhook(t, srv, payload, stripeSign(payload, "whsec_test_secret", time.Now())); r.StatusCode != http.StatusOK {
188 t.Fatalf("webhook: %d", r.StatusCode)
189 }
190 }
191
192 // Checkout completion seeds tier + band from the session metadata.
193 deliver(`{"type":"checkout.session.completed","data":{"object":{
194 "mode":"subscription","client_reference_id":"1","customer":"cus_1","subscription":"sub_1",
195 "metadata":{"tier":"friends","band":"reduced"}}}}`)
196 if tier, band := spaceBandTier(t, s, 1); tier != "friends" || band != "reduced" {
197 t.Fatalf("after checkout: %s/%s, want friends/reduced", tier, band)
198 }
199
200 // A plan switch to another band's price moves the recorded band — the
201 // subscription's PRICE is the truth, metadata only seeded the first value.
202 deliver(`{"type":"customer.subscription.updated","data":{"object":{
203 "id":"sub_1","status":"active","metadata":{"space_id":"1","tier":"friends","band":"reduced"},
204 "items":{"data":[{"current_period_end":1900000000,"price":{"id":"price_friends_m"}}]}}}}`)
205 if tier, band := spaceBandTier(t, s, 1); tier != "friends" || band != "base" {
206 t.Fatalf("after switch to base price: %s/%s, want friends/base", tier, band)
207 }
208
209 // An event without price data keeps the recorded band (COALESCE semantics).
210 deliver(`{"type":"customer.subscription.updated","data":{"object":{
211 "id":"sub_1","status":"active","metadata":{"space_id":"1"},"current_period_end":1900000099}}}`)
212 if _, band := spaceBandTier(t, s, 1); band != "base" {
213 t.Fatalf("band lost on a price-less event: %q", band)
214 }
215}
216
217func TestPortalBandConfig(t *testing.T) {
218 // A portal fake capturing the configuration param.
219 captured := url.Values{}
220 stripe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
221 if r.URL.Path != "/v1/billing_portal/sessions" {
222 w.WriteHeader(http.StatusNotFound)
223 return
224 }
225 _ = r.ParseForm()
226 captured = r.PostForm
227 writeJSON(w, http.StatusOK, map[string]any{"url": "https://billing.stripe.com/p/session_x"})
228 }))
229 defer stripe.Close()
230
231 srv, s, _ := billingTestCloud(t)
232 withBands(s)
233 s.billing.apiBase = stripe.URL
234 s.billing.portalConfig = "bpc_default"
235 s.billing.portalConfigs = map[string]string{"reduced": "bpc_reduced"}
236 tok := mintCaller(t, srv, "home")
237 host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string)
238 if err := s.db.markSpacePaid(1, "cus_1", "sub_1", "friends", "reduced"); err != nil {
239 t.Fatal(err)
240 }
241
242 postPortal := func() {
243 t.Helper()
244 body, _ := json.Marshal(map[string]string{"requester": "user-1", "return_url": "https://home.example.com/"})
245 req, _ := http.NewRequest("POST", srv.URL+"/api/instances/"+host+"/portal", bytes.NewReader(body))
246 req.Header.Set("Authorization", "Bearer "+tok)
247 resp, err := http.DefaultClient.Do(req)
248 if err != nil {
249 t.Fatal(err)
250 }
251 defer resp.Body.Close()
252 if resp.StatusCode != http.StatusOK {
253 t.Fatalf("portal: %d", resp.StatusCode)
254 }
255 }
256
257 // The reduced-band space gets the reduced portal configuration…
258 postPortal()
259 if got := captured.Get("configuration"); got != "bpc_reduced" {
260 t.Fatalf("configuration = %q, want bpc_reduced", got)
261 }
262 // …and a band without its own config falls back to the default.
263 if _, err := s.db.sql.Exec(`UPDATE spaces SET band = 'supporter' WHERE id = 1`); err != nil {
264 t.Fatal(err)
265 }
266 postPortal()
267 if got := captured.Get("configuration"); got != "bpc_default" {
268 t.Fatalf("fallback configuration = %q, want bpc_default", got)
269 }
270}
271
272func TestPublicTiersBands(t *testing.T) {
273 _, s, _ := billingTestCloud(t)
274 withBands(s)
275 out := s.billing.publicTiers()
276 var friends map[string]any
277 for _, e := range out {
278 if e["key"] == "friends" {
279 friends = e
280 }
281 }
282 if friends == nil {
283 t.Fatal("friends tier missing from listing")
284 }
285 if friends["eur"] != 4.0 {
286 t.Fatalf("eur = %v", friends["eur"])
287 }
288 bands, _ := friends["bands"].(map[string]any)
289 red, _ := bands["reduced"].(map[string]any)
290 if red == nil || red["label"] != "€2/month" || red["eur"] != 2.0 {
291 t.Fatalf("reduced band listing = %v", red)
292 }
293 // Price ids never cross the public boundary.
294 blob, _ := json.Marshal(out)
295 if strings.Contains(string(blob), "price_") {
296 t.Fatalf("listing leaks a price id: %s", blob)
297 }
298}
299
300func TestNetForTierBand(t *testing.T) {
301 _, s, _ := billingTestCloud(t)
302 withBands(s)
303 if net := s.netForTier("friends", "base", 0); net != 44 {
304 t.Fatalf("base net = %v", net)
305 }
306 if net := s.netForTier("friends", "", 0); net != 44 {
307 t.Fatalf("empty-band net = %v (pre-band rows read as base)", net)
308 }
309 if net := s.netForTier("friends", "reduced", 0); net != 21 {
310 t.Fatalf("reduced net = %v", net)
311 }
312 // A recorded band the book no longer names falls back to base, not zero.
313 if net := s.netForTier("friends", "supporter", 0); net != 44 {
314 t.Fatalf("retired-band net = %v", net)
315 }
316}
317