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// Per-seat tiers (docs/tier-economics.md § Per-seat): checkout charges the
4// live head count, webhooks mirror the billed seats into the ledger, storage
5// caps scale per seat, the pool nets per seat, and the meter sweep trues the
6// subscription quantity up in the runway before each renewal.
7
8import (
9 "database/sql"
10 "encoding/json"
11 "fmt"
12 "net/http"
13 "net/http/httptest"
14 "net/url"
15 "path/filepath"
16 "strings"
17 "testing"
18 "time"
19)
20
21// perSeatTiers is a book with one flat and one per-seat tier.
22func perSeatTiers() []billingTier {
23 return []billingTier{
24 {Key: "friends", Name: "Friends", Label: "€4/month", Price: "price_friends_m", NetEURYear: 44},
25 {Key: "business", Name: "Business", Label: "€11 per person per year", Price: "price_business_seat_y",
26 NetEURYear: 10.8, EUR: 11, Business: true, PerSeat: true},
27 {Key: "community", Name: "Community & Friends", Label: "€0.11 per member per year", Price: "price_community_member_y",
28 NetEURYear: 0.108, EUR: 0.11, PerSeat: true, MinSeats: 100},
29 }
30}
31
32// statsControl answers creates like okControl and stats with a fixed people
33// count, so per-seat checkouts can count heads.
34func statsControl(t *testing.T, members, people int, statsErr bool) string {
35 t.Helper()
36 mux := http.NewServeMux()
37 mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) {
38 if statsErr {
39 w.WriteHeader(http.StatusInternalServerError)
40 return
41 }
42 writeJSON(w, http.StatusOK, map[string]any{
43 "members": members, "people": people, "setup_complete": true, "has_content": true, "storage_bytes": 0,
44 })
45 })
46 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
47 var req controlCreateReq
48 json.NewDecoder(r.Body).Decode(&req)
49 writeJSON(w, http.StatusCreated, map[string]any{
50 "host": req.Host, "url": "https://" + req.Host, "setup_link": "https://" + req.Host + "/join/setup#SECRET",
51 })
52 })
53 return fakeControl(t, mux.ServeHTTP)
54}
55
56func TestParseStorageCapsPerSeat(t *testing.T) {
57 caps, seatCaps, err := parseStorageCaps("free=10GB,business=11GB/seat")
58 if err != nil {
59 t.Fatal(err)
60 }
61 if caps["free"] != 10e9 || len(caps) != 1 {
62 t.Errorf("flat caps = %v, want free=10GB only", caps)
63 }
64 if seatCaps["business"] != 11e9 || len(seatCaps) != 1 {
65 t.Errorf("seat caps = %v, want business=11GB", seatCaps)
66 }
67 if _, _, err := parseStorageCaps("business=banana/seat"); err == nil {
68 t.Error("bad per-seat size parsed")
69 }
70}
71
72func TestCapForPerSeat(t *testing.T) {
73 s := &cloudServer{
74 storageCaps: map[string]int64{"free": 1e9, "family": 25e9},
75 seatCaps: map[string]int64{"business": 11e9},
76 }
77 if got := s.capFor(entPaid, "business", 34); got != 34*11e9 {
78 t.Errorf("34 people = %d, want %d (pooled per-seat)", got, int64(34*11e9))
79 }
80 // A head count the meter hasn't seen yet floors at one seat, never zero.
81 if got := s.capFor(entPaid, "business", 0); got != 11e9 {
82 t.Errorf("0 people = %d, want one seat's cap", got)
83 }
84 if got := s.capFor(entPaid, "family", 34); got != 25e9 {
85 t.Errorf("flat tier = %d, want its flat cap regardless of people", got)
86 }
87 if got := s.capFor(entFree, "", 34); got != 1e9 {
88 t.Errorf("free = %d, want the free cap", got)
89 }
90}
91
92func TestCheckoutPerSeatQuantity(t *testing.T) {
93 stripe, captured := fakeStripe(t)
94 srv, s := newTestCloud(t, statsControl(t, 10, 7, false))
95 s.billing = &stripeBilling{
96 apiBase: stripe.URL, key: "sk_test", webhookSecret: "whsec_test",
97 tiers: perSeatTiers(), client: http.DefaultClient,
98 }
99 tok := mintCaller(t, srv, "home")
100 host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string)
101
102 resp := postCheckout(t, srv, tok, host, "user-1", "business")
103 if resp.StatusCode != http.StatusOK {
104 t.Fatalf("status = %d, want 200", resp.StatusCode)
105 }
106 f := *captured
107 // Quantity is the NON-AGENT count (7), not the raw member count (10) —
108 // bots are free.
109 if f.Get("line_items[0][quantity]") != "7" || f.Get("line_items[0][price]") != "price_business_seat_y" {
110 t.Errorf("line item = price %q × %q, want price_business_seat_y × 7",
111 f.Get("line_items[0][price]"), f.Get("line_items[0][quantity]"))
112 }
113
114 // Flat tiers keep charging exactly one.
115 resp = postCheckout(t, srv, tok, host, "user-1", "friends")
116 if resp.StatusCode != http.StatusOK {
117 t.Fatalf("flat status = %d, want 200", resp.StatusCode)
118 }
119 if q := (*captured).Get("line_items[0][quantity]"); q != "1" {
120 t.Errorf("flat quantity = %q, want 1", q)
121 }
122}
123
124func TestCheckoutPerSeatFailsClosedWithoutStats(t *testing.T) {
125 stripe, _ := fakeStripe(t)
126 srv, s := newTestCloud(t, statsControl(t, 0, 0, true))
127 s.billing = &stripeBilling{
128 apiBase: stripe.URL, key: "sk_test", webhookSecret: "whsec_test",
129 tiers: perSeatTiers(), client: http.DefaultClient,
130 }
131 tok := mintCaller(t, srv, "home")
132 host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string)
133
134 // No head count → no checkout; quietly charging one seat would undersell.
135 if resp := postCheckout(t, srv, tok, host, "user-1", "business"); resp.StatusCode != http.StatusBadGateway {
136 t.Errorf("per-seat status = %d, want 502 when stats are down", resp.StatusCode)
137 }
138 // The flat tier never needs stats, so it still mints.
139 if resp := postCheckout(t, srv, tok, host, "user-1", "friends"); resp.StatusCode != http.StatusOK {
140 t.Errorf("flat status = %d, want 200 (stats outage must not gate flat tiers)", resp.StatusCode)
141 }
142}
143
144func TestWebhookRecordsSeats(t *testing.T) {
145 srv, s := newTestCloud(t, statsControl(t, 10, 7, false))
146 s.billing = &stripeBilling{
147 apiBase: "http://unused", key: "sk_test", webhookSecret: "whsec_test",
148 tiers: perSeatTiers(), client: http.DefaultClient,
149 }
150 tok := mintCaller(t, srv, "home")
151 createSpace(t, srv, tok, "user-1")
152
153 ev := fmt.Sprintf(`{"type":"customer.subscription.updated","data":{"object":{
154 "id":"sub_1","status":"active","metadata":{"space_id":"1"},
155 "items":{"data":[{"current_period_end":%d,"quantity":12,"price":{"id":"price_business_seat_y"}}]}}}}`,
156 time.Now().Add(365*24*time.Hour).Unix())
157 sig := stripeSign([]byte(ev), "whsec_test", time.Now())
158 if resp := postWebhook(t, srv, []byte(ev), sig); resp.StatusCode != http.StatusOK {
159 t.Fatalf("webhook status = %d", resp.StatusCode)
160 }
161 var seats int
162 var tier string
163 if err := s.db.sql.QueryRow(`SELECT seats, tier FROM spaces WHERE id = 1`).Scan(&seats, &tier); err != nil {
164 t.Fatal(err)
165 }
166 if seats != 12 || tier != "business" {
167 t.Errorf("ledger = tier %q seats %d, want business/12 (mirrors Stripe's item)", tier, seats)
168 }
169
170 // A flat-tier event leaves the recorded seats alone (0 keeps, like ""
171 // keeps tier/band).
172 ev = fmt.Sprintf(`{"type":"customer.subscription.updated","data":{"object":{
173 "id":"sub_1","status":"active","metadata":{"space_id":"1"},
174 "items":{"data":[{"current_period_end":%d,"quantity":1,"price":{"id":"price_friends_m"}}]}}}}`,
175 time.Now().Add(30*24*time.Hour).Unix())
176 if resp := postWebhook(t, srv, []byte(ev), stripeSign([]byte(ev), "whsec_test", time.Now())); resp.StatusCode != http.StatusOK {
177 t.Fatalf("flat webhook status = %d", resp.StatusCode)
178 }
179 if err := s.db.sql.QueryRow(`SELECT seats, tier FROM spaces WHERE id = 1`).Scan(&seats, &tier); err != nil {
180 t.Fatal(err)
181 }
182 if tier != "friends" || seats != 12 {
183 t.Errorf("after flat switch = tier %q seats %d, want friends with seats kept at 12", tier, seats)
184 }
185}
186
187func TestPublicTiersPerSeat(t *testing.T) {
188 b := &stripeBilling{tiers: perSeatTiers()}
189 tiers := b.publicTiers()
190 if flag, _ := tiers[1]["per_seat"].(bool); !flag {
191 t.Errorf("business entry = %v, want per_seat true", tiers[1])
192 }
193 if _, present := tiers[0]["per_seat"]; present {
194 t.Errorf("friends entry = %v, want no per_seat key", tiers[0])
195 }
196}
197
198func TestNetForTierPerSeat(t *testing.T) {
199 s := &cloudServer{billing: &stripeBilling{tiers: perSeatTiers()}}
200 if want := 10.8 * float64(34); s.netForTier("business", "base", 34) != want {
201 t.Errorf("34 seats = %v, want %v", s.netForTier("business", "base", 34), want)
202 }
203 // Pre-seat rows (seats 0) floor at one seat rather than netting zero.
204 if net := s.netForTier("business", "base", 0); net != 10.8 {
205 t.Errorf("0 seats = %v, want one seat's net", net)
206 }
207 if net := s.netForTier("friends", "base", 34); net != 44 {
208 t.Errorf("flat tier = %v, want its flat net regardless of seats", net)
209 }
210}
211
212// seatStripe fakes the two subscription calls the true-up makes: GET returns
213// the item id + current quantity; POST captures the update form.
214func seatStripe(t *testing.T, quantity int64, updated *url.Values) *httptest.Server {
215 t.Helper()
216 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
217 if !strings.HasPrefix(r.URL.Path, "/v1/subscriptions/") {
218 w.WriteHeader(http.StatusNotFound)
219 return
220 }
221 if r.Method == "GET" {
222 writeJSON(w, http.StatusOK, map[string]any{
223 "items": map[string]any{"data": []map[string]any{{"id": "si_1", "quantity": quantity}}},
224 })
225 return
226 }
227 r.ParseForm()
228 *updated = r.PostForm
229 writeJSON(w, http.StatusOK, map[string]any{"id": strings.TrimPrefix(r.URL.Path, "/v1/subscriptions/")})
230 }))
231 t.Cleanup(srv.Close)
232 return srv
233}
234
235func TestSeatTrueUpAtRenewal(t *testing.T) {
236 db := openTestCloudDB(t)
237 id := insertSpaceRow(t, db, "biz.11ch.at", 1000)
238 renewAt := time.Now().Add(3 * 24 * time.Hour).Unix() // inside the 7-day window
239 if _, err := db.sql.Exec(
240 `UPDATE spaces SET entitlement='paid', tier='business', seats=5, paid_until=?, stripe_subscription_id='sub_1' WHERE id=?`,
241 renewAt, id); err != nil {
242 t.Fatal(err)
243 }
244
245 updated := url.Values{}
246 stripe := seatStripe(t, 5, &updated)
247 pushed := map[string]int64{}
248 mux := http.NewServeMux()
249 mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) {
250 writeJSON(w, http.StatusOK, map[string]any{"members": 10, "people": 7, "setup_complete": true, "storage_bytes": 1})
251 })
252 mux.HandleFunc("POST /instances/{host}/quota", func(w http.ResponseWriter, r *http.Request) {
253 var req struct {
254 Bytes int64 `json:"bytes"`
255 }
256 json.NewDecoder(r.Body).Decode(&req)
257 pushed[r.PathValue("host")] = req.Bytes
258 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
259 })
260 s := &cloudServer{
261 db: db, control: controlClient(fakeControl(t, mux.ServeHTTP)), controlToken: "t",
262 seatCaps: map[string]int64{"business": 11e9},
263 billing: &stripeBilling{apiBase: stripe.URL, key: "sk_test", tiers: perSeatTiers(), client: http.DefaultClient},
264 }
265
266 s.meterOnce()
267 // The sweep pushed the pooled per-seat cap (7 × 11 GB, people not members)…
268 if pushed["biz.11ch.at"] != 7*11e9 {
269 t.Errorf("quota = %d, want %d", pushed["biz.11ch.at"], int64(7*11e9))
270 }
271 // …and trued the subscription up to 7 seats for the next invoice, without
272 // mid-cycle proration.
273 if updated.Get("items[0][id]") != "si_1" || updated.Get("items[0][quantity]") != "7" {
274 t.Errorf("update form = %v, want si_1 → quantity 7", updated)
275 }
276 if updated.Get("proration_behavior") != "none" {
277 t.Errorf("proration_behavior = %q, want none — renewal-only, never a mid-cycle charge", updated.Get("proration_behavior"))
278 }
279}
280
281func TestSeatTrueUpHoldsOutsideWindowAndWhenMatched(t *testing.T) {
282 db := openTestCloudDB(t)
283 farID := insertSpaceRow(t, db, "far.11ch.at", 1000)
284 if _, err := db.sql.Exec(
285 `UPDATE spaces SET entitlement='paid', tier='business', seats=5, paid_until=?, stripe_subscription_id='sub_far' WHERE id=?`,
286 time.Now().Add(200*24*time.Hour).Unix(), farID); err != nil {
287 t.Fatal(err)
288 }
289 matchedID := insertSpaceRow(t, db, "matched.11ch.at", 1000)
290 if _, err := db.sql.Exec(
291 `UPDATE spaces SET entitlement='paid', tier='business', seats=7, paid_until=?, stripe_subscription_id='sub_m' WHERE id=?`,
292 time.Now().Add(2*24*time.Hour).Unix(), matchedID); err != nil {
293 t.Fatal(err)
294 }
295
296 stripeCalls := 0
297 stripe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
298 stripeCalls++
299 writeJSON(w, http.StatusOK, map[string]any{
300 "items": map[string]any{"data": []map[string]any{{"id": "si_1", "quantity": 5}}},
301 })
302 }))
303 t.Cleanup(stripe.Close)
304 mux := http.NewServeMux()
305 mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) {
306 writeJSON(w, http.StatusOK, map[string]any{"members": 7, "people": 7, "setup_complete": true, "storage_bytes": 1})
307 })
308 mux.HandleFunc("POST /instances/{host}/quota", func(w http.ResponseWriter, r *http.Request) {
309 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
310 })
311 s := &cloudServer{
312 db: db, control: controlClient(fakeControl(t, mux.ServeHTTP)), controlToken: "t",
313 billing: &stripeBilling{apiBase: stripe.URL, key: "sk_test", tiers: perSeatTiers(), client: http.DefaultClient},
314 }
315
316 s.meterOnce()
317 // far is months from renewal; matched already bills its live count — the
318 // sweep must not touch Stripe for either.
319 if stripeCalls != 0 {
320 t.Errorf("stripe calls = %d, want 0 (outside window / already matched)", stripeCalls)
321 }
322}
323
324func TestTrueUpSeatsNoopsWhenStripeMatches(t *testing.T) {
325 updated := url.Values{}
326 stripe := seatStripe(t, 7, &updated)
327 b := &stripeBilling{apiBase: stripe.URL, key: "sk_test", client: http.DefaultClient}
328 changed, err := b.trueUpSeats("sub_1", 7)
329 if err != nil || changed {
330 t.Errorf("trueUpSeats = (%v, %v), want no-op when quantities already match", changed, err)
331 }
332 if len(updated) != 0 {
333 t.Errorf("update form = %v, want no POST", updated)
334 }
335}
336
337func TestInstanceStatusPeople(t *testing.T) {
338 dir := t.TempDir()
339 prev := instanceDataDir
340 instanceDataDir = dir
341 defer func() { instanceDataDir = prev }()
342
343 // Two people + one bot: members counts all three, people excludes the bot.
344 db, err := openDB(filepath.Join(dir, "bots.11ch.at.db"))
345 if err != nil {
346 t.Fatal(err)
347 }
348 defer db.sql.Close()
349 if _, err := db.sql.Exec(`INSERT INTO users(id, created_at, is_agent) VALUES('admin',1,0),('pal',1,0),('eleven',1,1)`); err != nil {
350 t.Fatal(err)
351 }
352 if m, p, _, _, err := instanceStatus("bots.11ch.at"); err != nil || m != 3 || p != 2 {
353 t.Errorf("instanceStatus = (members %d, people %d, %v); want (3, 2, nil)", m, p, err)
354 }
355
356 // A DB from before is_agent existed: people falls back to the full count
357 // rather than failing the stats read.
358 legacyPath := filepath.Join(dir, "legacy.11ch.at.db")
359 legacy, err := sql.Open("sqlite", legacyPath)
360 if err != nil {
361 t.Fatal(err)
362 }
363 if _, err := legacy.Exec(`
364CREATE TABLE users (id TEXT PRIMARY KEY, created_at INTEGER);
365CREATE TABLE config (key TEXT PRIMARY KEY, value TEXT);
366CREATE TABLE messages (id TEXT PRIMARY KEY);
367CREATE TABLE threads (id TEXT PRIMARY KEY);
368INSERT INTO users(id, created_at) VALUES('admin',1),('pal',1);`); err != nil {
369 t.Fatal(err)
370 }
371 legacy.Close()
372 if m, p, _, _, err := instanceStatus("legacy.11ch.at"); err != nil || m != 2 || p != 2 {
373 t.Errorf("legacy instanceStatus = (members %d, people %d, %v); want (2, 2, nil)", m, p, err)
374 }
375}
376
377func TestCheckoutMinSeatsFloor(t *testing.T) {
378 stripe, captured := fakeStripe(t)
379 srv, s := newTestCloud(t, statsControl(t, 10, 7, false))
380 s.billing = &stripeBilling{
381 apiBase: stripe.URL, key: "sk_test", webhookSecret: "whsec_test",
382 tiers: perSeatTiers(), client: http.DefaultClient,
383 }
384 tok := mintCaller(t, srv, "home")
385 host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string)
386
387 // 7 people on a min-100 tier bill the floor — €0.11 × 100 IS the €11
388 // minimum with the first 100 members included, no separate Price.
389 resp := postCheckout(t, srv, tok, host, "user-1", "community")
390 if resp.StatusCode != http.StatusOK {
391 t.Fatalf("status = %d, want 200", resp.StatusCode)
392 }
393 if q := (*captured).Get("line_items[0][quantity]"); q != "100" {
394 t.Errorf("quantity = %q, want the 100-seat floor over 7 people", q)
395 }
396}
397
398func TestCheckoutMinSeatsAboveFloor(t *testing.T) {
399 stripe, captured := fakeStripe(t)
400 srv, s := newTestCloud(t, statsControl(t, 150, 140, false))
401 s.billing = &stripeBilling{
402 apiBase: stripe.URL, key: "sk_test", webhookSecret: "whsec_test",
403 tiers: perSeatTiers(), client: http.DefaultClient,
404 }
405 tok := mintCaller(t, srv, "home")
406 host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string)
407
408 resp := postCheckout(t, srv, tok, host, "user-1", "community")
409 if resp.StatusCode != http.StatusOK {
410 t.Fatalf("status = %d, want 200", resp.StatusCode)
411 }
412 if q := (*captured).Get("line_items[0][quantity]"); q != "140" {
413 t.Errorf("quantity = %q, want 140 (the live count once past the floor)", q)
414 }
415}
416
417func TestSeatTrueUpFloorsAtMinSeats(t *testing.T) {
418 db := openTestCloudDB(t)
419 id := insertSpaceRow(t, db, "club.11ch.at", 1000)
420 renewAt := time.Now().Add(3 * 24 * time.Hour).Unix()
421 if _, err := db.sql.Exec(
422 `UPDATE spaces SET entitlement='paid', tier='community', seats=5, paid_until=?, stripe_subscription_id='sub_1' WHERE id=?`,
423 renewAt, id); err != nil {
424 t.Fatal(err)
425 }
426
427 updated := url.Values{}
428 stripe := seatStripe(t, 5, &updated)
429 mux := http.NewServeMux()
430 mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) {
431 writeJSON(w, http.StatusOK, map[string]any{"members": 10, "people": 7, "setup_complete": true, "storage_bytes": 1})
432 })
433 mux.HandleFunc("POST /instances/{host}/quota", func(w http.ResponseWriter, r *http.Request) {
434 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
435 })
436 s := &cloudServer{
437 db: db, control: controlClient(fakeControl(t, mux.ServeHTTP)), controlToken: "t",
438 billing: &stripeBilling{apiBase: stripe.URL, key: "sk_test", tiers: perSeatTiers(), client: http.DefaultClient},
439 }
440
441 s.meterOnce()
442 // Renewal never trues up below the floor: 7 people still renew at 100.
443 if updated.Get("items[0][quantity]") != "100" {
444 t.Errorf("update form = %v, want quantity trued up to the 100-seat floor", updated)
445 }
446}
447