| 1 | package 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 | |
| 8 | import ( |
| 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. |
| 22 | func 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. |
| 34 | func 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 | |
| 56 | func 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 | |
| 72 | func 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 | |
| 92 | func 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 | |
| 124 | func 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 | |
| 144 | func 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 | |
| 187 | func 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 | |
| 198 | func 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. |
| 214 | func 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 | |
| 235 | func 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 | |
| 281 | func 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 | |
| 324 | func 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 | |
| 337 | func 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(` |
| 364 | CREATE TABLE users (id TEXT PRIMARY KEY, created_at INTEGER); |
| 365 | CREATE TABLE config (key TEXT PRIMARY KEY, value TEXT); |
| 366 | CREATE TABLE messages (id TEXT PRIMARY KEY); |
| 367 | CREATE TABLE threads (id TEXT PRIMARY KEY); |
| 368 | INSERT 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 | |
| 377 | func 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 | |
| 398 | func 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 | |
| 417 | func 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 | |