package main // Per-seat tiers (docs/tier-economics.md § Per-seat): checkout charges the // live head count, webhooks mirror the billed seats into the ledger, storage // caps scale per seat, the pool nets per seat, and the meter sweep trues the // subscription quantity up in the runway before each renewal. import ( "database/sql" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "time" ) // perSeatTiers is a book with one flat and one per-seat tier. func perSeatTiers() []billingTier { return []billingTier{ {Key: "friends", Name: "Friends", Label: "€4/month", Price: "price_friends_m", NetEURYear: 44}, {Key: "business", Name: "Business", Label: "€11 per person per year", Price: "price_business_seat_y", NetEURYear: 10.8, EUR: 11, Business: true, PerSeat: true}, {Key: "community", Name: "Community & Friends", Label: "€0.11 per member per year", Price: "price_community_member_y", NetEURYear: 0.108, EUR: 0.11, PerSeat: true, MinSeats: 100}, } } // statsControl answers creates like okControl and stats with a fixed people // count, so per-seat checkouts can count heads. func statsControl(t *testing.T, members, people int, statsErr bool) string { t.Helper() mux := http.NewServeMux() mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) { if statsErr { w.WriteHeader(http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]any{ "members": members, "people": people, "setup_complete": true, "has_content": true, "storage_bytes": 0, }) }) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { var req controlCreateReq json.NewDecoder(r.Body).Decode(&req) writeJSON(w, http.StatusCreated, map[string]any{ "host": req.Host, "url": "https://" + req.Host, "setup_link": "https://" + req.Host + "/join/setup#SECRET", }) }) return fakeControl(t, mux.ServeHTTP) } func TestParseStorageCapsPerSeat(t *testing.T) { caps, seatCaps, err := parseStorageCaps("free=10GB,business=11GB/seat") if err != nil { t.Fatal(err) } if caps["free"] != 10e9 || len(caps) != 1 { t.Errorf("flat caps = %v, want free=10GB only", caps) } if seatCaps["business"] != 11e9 || len(seatCaps) != 1 { t.Errorf("seat caps = %v, want business=11GB", seatCaps) } if _, _, err := parseStorageCaps("business=banana/seat"); err == nil { t.Error("bad per-seat size parsed") } } func TestCapForPerSeat(t *testing.T) { s := &cloudServer{ storageCaps: map[string]int64{"free": 1e9, "family": 25e9}, seatCaps: map[string]int64{"business": 11e9}, } if got := s.capFor(entPaid, "business", 34); got != 34*11e9 { t.Errorf("34 people = %d, want %d (pooled per-seat)", got, int64(34*11e9)) } // A head count the meter hasn't seen yet floors at one seat, never zero. if got := s.capFor(entPaid, "business", 0); got != 11e9 { t.Errorf("0 people = %d, want one seat's cap", got) } if got := s.capFor(entPaid, "family", 34); got != 25e9 { t.Errorf("flat tier = %d, want its flat cap regardless of people", got) } if got := s.capFor(entFree, "", 34); got != 1e9 { t.Errorf("free = %d, want the free cap", got) } } func TestCheckoutPerSeatQuantity(t *testing.T) { stripe, captured := fakeStripe(t) srv, s := newTestCloud(t, statsControl(t, 10, 7, false)) s.billing = &stripeBilling{ apiBase: stripe.URL, key: "sk_test", webhookSecret: "whsec_test", tiers: perSeatTiers(), client: http.DefaultClient, } tok := mintCaller(t, srv, "home") host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) resp := postCheckout(t, srv, tok, host, "user-1", "business") if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } f := *captured // Quantity is the NON-AGENT count (7), not the raw member count (10) — // bots are free. if f.Get("line_items[0][quantity]") != "7" || f.Get("line_items[0][price]") != "price_business_seat_y" { t.Errorf("line item = price %q × %q, want price_business_seat_y × 7", f.Get("line_items[0][price]"), f.Get("line_items[0][quantity]")) } // Flat tiers keep charging exactly one. resp = postCheckout(t, srv, tok, host, "user-1", "friends") if resp.StatusCode != http.StatusOK { t.Fatalf("flat status = %d, want 200", resp.StatusCode) } if q := (*captured).Get("line_items[0][quantity]"); q != "1" { t.Errorf("flat quantity = %q, want 1", q) } } func TestCheckoutPerSeatFailsClosedWithoutStats(t *testing.T) { stripe, _ := fakeStripe(t) srv, s := newTestCloud(t, statsControl(t, 0, 0, true)) s.billing = &stripeBilling{ apiBase: stripe.URL, key: "sk_test", webhookSecret: "whsec_test", tiers: perSeatTiers(), client: http.DefaultClient, } tok := mintCaller(t, srv, "home") host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) // No head count → no checkout; quietly charging one seat would undersell. if resp := postCheckout(t, srv, tok, host, "user-1", "business"); resp.StatusCode != http.StatusBadGateway { t.Errorf("per-seat status = %d, want 502 when stats are down", resp.StatusCode) } // The flat tier never needs stats, so it still mints. if resp := postCheckout(t, srv, tok, host, "user-1", "friends"); resp.StatusCode != http.StatusOK { t.Errorf("flat status = %d, want 200 (stats outage must not gate flat tiers)", resp.StatusCode) } } func TestWebhookRecordsSeats(t *testing.T) { srv, s := newTestCloud(t, statsControl(t, 10, 7, false)) s.billing = &stripeBilling{ apiBase: "http://unused", key: "sk_test", webhookSecret: "whsec_test", tiers: perSeatTiers(), client: http.DefaultClient, } tok := mintCaller(t, srv, "home") createSpace(t, srv, tok, "user-1") ev := fmt.Sprintf(`{"type":"customer.subscription.updated","data":{"object":{ "id":"sub_1","status":"active","metadata":{"space_id":"1"}, "items":{"data":[{"current_period_end":%d,"quantity":12,"price":{"id":"price_business_seat_y"}}]}}}}`, time.Now().Add(365*24*time.Hour).Unix()) sig := stripeSign([]byte(ev), "whsec_test", time.Now()) if resp := postWebhook(t, srv, []byte(ev), sig); resp.StatusCode != http.StatusOK { t.Fatalf("webhook status = %d", resp.StatusCode) } var seats int var tier string if err := s.db.sql.QueryRow(`SELECT seats, tier FROM spaces WHERE id = 1`).Scan(&seats, &tier); err != nil { t.Fatal(err) } if seats != 12 || tier != "business" { t.Errorf("ledger = tier %q seats %d, want business/12 (mirrors Stripe's item)", tier, seats) } // A flat-tier event leaves the recorded seats alone (0 keeps, like "" // keeps tier/band). ev = fmt.Sprintf(`{"type":"customer.subscription.updated","data":{"object":{ "id":"sub_1","status":"active","metadata":{"space_id":"1"}, "items":{"data":[{"current_period_end":%d,"quantity":1,"price":{"id":"price_friends_m"}}]}}}}`, time.Now().Add(30*24*time.Hour).Unix()) if resp := postWebhook(t, srv, []byte(ev), stripeSign([]byte(ev), "whsec_test", time.Now())); resp.StatusCode != http.StatusOK { t.Fatalf("flat webhook status = %d", resp.StatusCode) } if err := s.db.sql.QueryRow(`SELECT seats, tier FROM spaces WHERE id = 1`).Scan(&seats, &tier); err != nil { t.Fatal(err) } if tier != "friends" || seats != 12 { t.Errorf("after flat switch = tier %q seats %d, want friends with seats kept at 12", tier, seats) } } func TestPublicTiersPerSeat(t *testing.T) { b := &stripeBilling{tiers: perSeatTiers()} tiers := b.publicTiers() if flag, _ := tiers[1]["per_seat"].(bool); !flag { t.Errorf("business entry = %v, want per_seat true", tiers[1]) } if _, present := tiers[0]["per_seat"]; present { t.Errorf("friends entry = %v, want no per_seat key", tiers[0]) } } func TestNetForTierPerSeat(t *testing.T) { s := &cloudServer{billing: &stripeBilling{tiers: perSeatTiers()}} if want := 10.8 * float64(34); s.netForTier("business", "base", 34) != want { t.Errorf("34 seats = %v, want %v", s.netForTier("business", "base", 34), want) } // Pre-seat rows (seats 0) floor at one seat rather than netting zero. if net := s.netForTier("business", "base", 0); net != 10.8 { t.Errorf("0 seats = %v, want one seat's net", net) } if net := s.netForTier("friends", "base", 34); net != 44 { t.Errorf("flat tier = %v, want its flat net regardless of seats", net) } } // seatStripe fakes the two subscription calls the true-up makes: GET returns // the item id + current quantity; POST captures the update form. func seatStripe(t *testing.T, quantity int64, updated *url.Values) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/v1/subscriptions/") { w.WriteHeader(http.StatusNotFound) return } if r.Method == "GET" { writeJSON(w, http.StatusOK, map[string]any{ "items": map[string]any{"data": []map[string]any{{"id": "si_1", "quantity": quantity}}}, }) return } r.ParseForm() *updated = r.PostForm writeJSON(w, http.StatusOK, map[string]any{"id": strings.TrimPrefix(r.URL.Path, "/v1/subscriptions/")}) })) t.Cleanup(srv.Close) return srv } func TestSeatTrueUpAtRenewal(t *testing.T) { db := openTestCloudDB(t) id := insertSpaceRow(t, db, "biz.11ch.at", 1000) renewAt := time.Now().Add(3 * 24 * time.Hour).Unix() // inside the 7-day window if _, err := db.sql.Exec( `UPDATE spaces SET entitlement='paid', tier='business', seats=5, paid_until=?, stripe_subscription_id='sub_1' WHERE id=?`, renewAt, id); err != nil { t.Fatal(err) } updated := url.Values{} stripe := seatStripe(t, 5, &updated) pushed := map[string]int64{} mux := http.NewServeMux() mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"members": 10, "people": 7, "setup_complete": true, "storage_bytes": 1}) }) mux.HandleFunc("POST /instances/{host}/quota", func(w http.ResponseWriter, r *http.Request) { var req struct { Bytes int64 `json:"bytes"` } json.NewDecoder(r.Body).Decode(&req) pushed[r.PathValue("host")] = req.Bytes writeJSON(w, http.StatusOK, map[string]any{"ok": true}) }) s := &cloudServer{ db: db, control: controlClient(fakeControl(t, mux.ServeHTTP)), controlToken: "t", seatCaps: map[string]int64{"business": 11e9}, billing: &stripeBilling{apiBase: stripe.URL, key: "sk_test", tiers: perSeatTiers(), client: http.DefaultClient}, } s.meterOnce() // The sweep pushed the pooled per-seat cap (7 × 11 GB, people not members)… if pushed["biz.11ch.at"] != 7*11e9 { t.Errorf("quota = %d, want %d", pushed["biz.11ch.at"], int64(7*11e9)) } // …and trued the subscription up to 7 seats for the next invoice, without // mid-cycle proration. if updated.Get("items[0][id]") != "si_1" || updated.Get("items[0][quantity]") != "7" { t.Errorf("update form = %v, want si_1 → quantity 7", updated) } if updated.Get("proration_behavior") != "none" { t.Errorf("proration_behavior = %q, want none — renewal-only, never a mid-cycle charge", updated.Get("proration_behavior")) } } func TestSeatTrueUpHoldsOutsideWindowAndWhenMatched(t *testing.T) { db := openTestCloudDB(t) farID := insertSpaceRow(t, db, "far.11ch.at", 1000) if _, err := db.sql.Exec( `UPDATE spaces SET entitlement='paid', tier='business', seats=5, paid_until=?, stripe_subscription_id='sub_far' WHERE id=?`, time.Now().Add(200*24*time.Hour).Unix(), farID); err != nil { t.Fatal(err) } matchedID := insertSpaceRow(t, db, "matched.11ch.at", 1000) if _, err := db.sql.Exec( `UPDATE spaces SET entitlement='paid', tier='business', seats=7, paid_until=?, stripe_subscription_id='sub_m' WHERE id=?`, time.Now().Add(2*24*time.Hour).Unix(), matchedID); err != nil { t.Fatal(err) } stripeCalls := 0 stripe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { stripeCalls++ writeJSON(w, http.StatusOK, map[string]any{ "items": map[string]any{"data": []map[string]any{{"id": "si_1", "quantity": 5}}}, }) })) t.Cleanup(stripe.Close) mux := http.NewServeMux() mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"members": 7, "people": 7, "setup_complete": true, "storage_bytes": 1}) }) mux.HandleFunc("POST /instances/{host}/quota", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true}) }) s := &cloudServer{ db: db, control: controlClient(fakeControl(t, mux.ServeHTTP)), controlToken: "t", billing: &stripeBilling{apiBase: stripe.URL, key: "sk_test", tiers: perSeatTiers(), client: http.DefaultClient}, } s.meterOnce() // far is months from renewal; matched already bills its live count — the // sweep must not touch Stripe for either. if stripeCalls != 0 { t.Errorf("stripe calls = %d, want 0 (outside window / already matched)", stripeCalls) } } func TestTrueUpSeatsNoopsWhenStripeMatches(t *testing.T) { updated := url.Values{} stripe := seatStripe(t, 7, &updated) b := &stripeBilling{apiBase: stripe.URL, key: "sk_test", client: http.DefaultClient} changed, err := b.trueUpSeats("sub_1", 7) if err != nil || changed { t.Errorf("trueUpSeats = (%v, %v), want no-op when quantities already match", changed, err) } if len(updated) != 0 { t.Errorf("update form = %v, want no POST", updated) } } func TestInstanceStatusPeople(t *testing.T) { dir := t.TempDir() prev := instanceDataDir instanceDataDir = dir defer func() { instanceDataDir = prev }() // Two people + one bot: members counts all three, people excludes the bot. db, err := openDB(filepath.Join(dir, "bots.11ch.at.db")) if err != nil { t.Fatal(err) } defer db.sql.Close() 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 { t.Fatal(err) } if m, p, _, _, err := instanceStatus("bots.11ch.at"); err != nil || m != 3 || p != 2 { t.Errorf("instanceStatus = (members %d, people %d, %v); want (3, 2, nil)", m, p, err) } // A DB from before is_agent existed: people falls back to the full count // rather than failing the stats read. legacyPath := filepath.Join(dir, "legacy.11ch.at.db") legacy, err := sql.Open("sqlite", legacyPath) if err != nil { t.Fatal(err) } if _, err := legacy.Exec(` CREATE TABLE users (id TEXT PRIMARY KEY, created_at INTEGER); CREATE TABLE config (key TEXT PRIMARY KEY, value TEXT); CREATE TABLE messages (id TEXT PRIMARY KEY); CREATE TABLE threads (id TEXT PRIMARY KEY); INSERT INTO users(id, created_at) VALUES('admin',1),('pal',1);`); err != nil { t.Fatal(err) } legacy.Close() if m, p, _, _, err := instanceStatus("legacy.11ch.at"); err != nil || m != 2 || p != 2 { t.Errorf("legacy instanceStatus = (members %d, people %d, %v); want (2, 2, nil)", m, p, err) } } func TestCheckoutMinSeatsFloor(t *testing.T) { stripe, captured := fakeStripe(t) srv, s := newTestCloud(t, statsControl(t, 10, 7, false)) s.billing = &stripeBilling{ apiBase: stripe.URL, key: "sk_test", webhookSecret: "whsec_test", tiers: perSeatTiers(), client: http.DefaultClient, } tok := mintCaller(t, srv, "home") host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) // 7 people on a min-100 tier bill the floor — €0.11 × 100 IS the €11 // minimum with the first 100 members included, no separate Price. resp := postCheckout(t, srv, tok, host, "user-1", "community") if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } if q := (*captured).Get("line_items[0][quantity]"); q != "100" { t.Errorf("quantity = %q, want the 100-seat floor over 7 people", q) } } func TestCheckoutMinSeatsAboveFloor(t *testing.T) { stripe, captured := fakeStripe(t) srv, s := newTestCloud(t, statsControl(t, 150, 140, false)) s.billing = &stripeBilling{ apiBase: stripe.URL, key: "sk_test", webhookSecret: "whsec_test", tiers: perSeatTiers(), client: http.DefaultClient, } tok := mintCaller(t, srv, "home") host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) resp := postCheckout(t, srv, tok, host, "user-1", "community") if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } if q := (*captured).Get("line_items[0][quantity]"); q != "140" { t.Errorf("quantity = %q, want 140 (the live count once past the floor)", q) } } func TestSeatTrueUpFloorsAtMinSeats(t *testing.T) { db := openTestCloudDB(t) id := insertSpaceRow(t, db, "club.11ch.at", 1000) renewAt := time.Now().Add(3 * 24 * time.Hour).Unix() if _, err := db.sql.Exec( `UPDATE spaces SET entitlement='paid', tier='community', seats=5, paid_until=?, stripe_subscription_id='sub_1' WHERE id=?`, renewAt, id); err != nil { t.Fatal(err) } updated := url.Values{} stripe := seatStripe(t, 5, &updated) mux := http.NewServeMux() mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"members": 10, "people": 7, "setup_complete": true, "storage_bytes": 1}) }) mux.HandleFunc("POST /instances/{host}/quota", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true}) }) s := &cloudServer{ db: db, control: controlClient(fakeControl(t, mux.ServeHTTP)), controlToken: "t", billing: &stripeBilling{apiBase: stripe.URL, key: "sk_test", tiers: perSeatTiers(), client: http.DefaultClient}, } s.meterOnce() // Renewal never trues up below the floor: 7 people still renew at 100. if updated.Get("items[0][quantity]") != "100" { t.Errorf("update form = %v, want quantity trued up to the 100-seat floor", updated) } }