server: per-seat Business — €11/person/year, renewal true-up, pooled 11GB/seat caps (#1284)
The Business tier flips from flat €99/m–€999/y to per-seat annual pricing
(docs/tier-economics.md § Per-seat, decided 2026-07-22):
- Price book: per_seat tiers charge quantity = the space's live non-agent
member count at checkout (people, counted server-side in controld's stats;
bots are free). Fails closed if the count is unavailable.
- Ledger: spaces.seats mirrors what Stripe's subscription item actually
bills, via webhook.
- Dropoffs/growth: the meter sweep trues the subscription quantity up in the
week before each annual renewal (proration_behavior=none) — shrinkage
renews cheaper, growth renews dearer, nothing charged mid-cycle.
- Storage: LCHAT_CLOUD_STORAGE_CAPS grows a /seat suffix
(business=11GB/seat) — pooled cap = 11 GB × head count, reconciled by the
same sweep.
- Free pool: per-seat net_eur_year scales by billed seats.
- Status page: economics.{json,html} re-derived for the per-seat book;
contract arithmetic holds. Docs: tier-economics, pricing-bands (per-seat
band row, Business Solidarity dropped), cloud-provisioner.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
20 files changed,
+866
−132
billing.go+110 −9changelog/2026-07-22-business-per-seat.md+6 −0cloud.go+9 −4cloud_bands_test.go+4 −4cloud_billing_test.go+1 −1cloud_db.go+30 −13cloud_perseat_test.go+373 −0controld.go+3 −3demo.go+2 −2demo_test.go+2 −2docs/cloud-provisioner.md+3 −2docs/pricing-bands.md+8 −3docs/tier-economics.md+55 −16meter.go+78 −17meter_test.go+3 −3pool.go+13 −6reaper.go+22 −8reaper_test.go+4 −4web/status/economics.html+18 −7web/status/economics.json+122 −28
diff --git a/billing.go b/billing.go| index a48945c..974051e 100644 |
| --- a/billing.go |
| +++ b/billing.go |
| @@ -64,6 +64,12 @@ type billingTier struct { |
| // (created with tax_behavior=exclusive) and its checkout collects a VAT ID |
| // so EU businesses get the reverse charge (docs/pricing-bands.md § VAT). |
| Business bool `json:"business,omitempty"` |
| + // PerSeat marks a per-seat tier (docs/tier-economics.md § Per-seat): its |
| + // price (and each band's) is per person per year, the checkout charges |
| + // quantity = the space's live non-agent member count, and the renewal |
| + // true-up (meter.go) re-counts before each renewal so the next invoice |
| + // bills the real team size. NetEURYear/EUR are likewise per seat. |
| + PerSeat bool `json:"per_seat,omitempty"` |
| // Bands are the honor prices for this tier (docs/pricing-bands.md): the |
| // same product at supporter/reduced/solidarity multipliers, each its own |
| // Stripe Price. The tier's own label/price IS the base band. Optional and |
| @@ -163,6 +169,9 @@ func (b *stripeBilling) publicTiers() []map[string]any { |
| if t.Business { |
| entry["business"] = true |
| } |
| + if t.PerSeat { |
| + entry["per_seat"] = true |
| + } |
| if len(t.Bands) > 0 { |
| bands := map[string]any{} |
| for band, bd := range t.Bands { |
| @@ -295,7 +304,21 @@ func (s *cloudServer) checkoutHandler(w http.ResponseWriter, r *http.Request) { |
| writeControlErr(w, http.StatusConflict, "already_upgraded", "this space is already upgraded") |
| return |
| } |
| - _, checkoutURL, err := s.billing.createCheckoutSession(id, tier, band, price, req.SuccessURL, req.CancelURL, false) |
| + // Per-seat tiers charge quantity = the space's live head count, counted |
| + // server-side (non-agent members — bots are free), never self-declared. |
| + // Fail closed when the count is unavailable: quietly charging one seat |
| + // would betray the sticker. |
| + seats := 1 |
| + if tier.PerSeat { |
| + st, err := s.controlStats(host) |
| + if err != nil { |
| + log.Printf("cloud: checkout for %s (tier %s): seat count unavailable: %v", host, tier.Key, err) |
| + writeControlErr(w, http.StatusBadGateway, "checkout_failed", "couldn't count the space's members — please try again") |
| + return |
| + } |
| + seats = max(1, st.People) |
| + } |
| + _, checkoutURL, err := s.billing.createCheckoutSession(id, tier, band, price, seats, req.SuccessURL, req.CancelURL, false) |
| if err != nil { |
| log.Printf("cloud: checkout for %s (id %d, tier %s, band %s): %v", host, id, tier.Key, band, err) |
| writeControlErr(w, http.StatusBadGateway, "checkout_failed", "couldn't start the checkout — please try again") |
| @@ -409,7 +432,9 @@ func (s *cloudServer) checkoutCreateHandler(w http.ResponseWriter, r *http.Reque |
| writeControlErr(w, http.StatusInternalServerError, "checkout_failed", "db error") |
| return |
| } |
| - session, checkoutURL, err := s.billing.createCheckoutSession(id, tier, band, price, req.SuccessURL, req.CancelURL, true) |
| + // A pay-first space has exactly its creator until people join, so per-seat |
| + // tiers open at one seat; growth settles at the renewal true-up. |
| + session, checkoutURL, err := s.billing.createCheckoutSession(id, tier, band, price, 1, req.SuccessURL, req.CancelURL, true) |
| if err != nil { |
| s.db.deleteSpaceByID(id) // nothing bought yet — roll the allocation back |
| log.Printf("cloud: checkout-create (id %d, tier %s, band %s): %v", id, tier.Key, band, err) |
| @@ -522,7 +547,9 @@ func (s *cloudServer) claimCreateHandler(w http.ResponseWriter, r *http.Request) |
| log.Printf("cloud: claim-create finalize %s (id %d): %v", host, id, err) |
| } |
| log.Printf("cloud: claim-create provisioned %s (id %d, caller %.8s…)", host, id, callerID) |
| - s.pushQuota(id, host, entPaid, sess.Metadata["tier"]) |
| + // One person so far (the creator) — the meter sweep grows a per-seat quota |
| + // as members join. |
| + s.pushQuota(id, host, entPaid, sess.Metadata["tier"], 1) |
| if s.usageURL != "" { |
| s.pushMeterToken(id, host) |
| } |
| @@ -681,16 +708,17 @@ func validRedirectURL(raw string) bool { |
| // ledger id rides in client_reference_id (routes checkout.session.completed) |
| // and in the subscription's metadata (routes every later subscription event); |
| // the tier + band keys ride in the session metadata so completion records what |
| -// was bought. price is the band-resolved Stripe price id. pendingCreate marks |
| -// a pay-first session (the row is an allocation, not a space yet) — the claim |
| +// was bought. price is the band-resolved Stripe price id; seats is 1 on flat |
| +// tiers and the live head count on per-seat ones. pendingCreate marks a |
| +// pay-first session (the row is an allocation, not a space yet) — the claim |
| // path demands the marker so an upgrade session can never provision anything. |
| // Returns the session's id and hosted-page URL. |
| -func (b *stripeBilling) createCheckoutSession(spaceID int64, tier billingTier, band, price, successURL, cancelURL string, pendingCreate bool) (sessionID, sessionURL string, err error) { |
| +func (b *stripeBilling) createCheckoutSession(spaceID int64, tier billingTier, band, price string, seats int, successURL, cancelURL string, pendingCreate bool) (sessionID, sessionURL string, err error) { |
| id := strconv.FormatInt(spaceID, 10) |
| form := url.Values{} |
| form.Set("mode", "subscription") |
| form.Set("line_items[0][price]", price) |
| - form.Set("line_items[0][quantity]", "1") |
| + form.Set("line_items[0][quantity]", strconv.Itoa(seats)) |
| form.Set("client_reference_id", id) |
| form.Set("metadata[tier]", tier.Key) |
| form.Set("metadata[band]", band) |
| @@ -789,6 +817,71 @@ func (b *stripeBilling) retrieveCheckoutSession(sessionID string) (stripeCheckou |
| return body.stripeCheckoutSession, nil |
| } |
| +// trueUpSeats sets a subscription's quantity to seats with |
| +// proration_behavior=none: no mid-cycle charge or credit, the NEXT invoice |
| +// bills the new count — the whole dropoff/growth policy in one Stripe knob |
| +// (docs/tier-economics.md § Per-seat). Reads the subscription first for its |
| +// item id and current quantity; a matching quantity is a no-op (changed |
| +// false), so the sweep can call this repeatedly without touching Stripe state. |
| +func (b *stripeBilling) trueUpSeats(subscriptionID string, seats int) (changed bool, err error) { |
| + req, err := http.NewRequest("GET", b.apiBase+"/v1/subscriptions/"+subscriptionID, nil) |
| + if err != nil { |
| + return false, err |
| + } |
| + req.Header.Set("Authorization", "Bearer "+b.key) |
| + resp, err := b.client.Do(req) |
| + if err != nil { |
| + return false, err |
| + } |
| + defer resp.Body.Close() |
| + var sub struct { |
| + Items struct { |
| + Data []struct { |
| + ID string `json:"id"` |
| + Quantity int64 `json:"quantity"` |
| + } `json:"data"` |
| + } `json:"items"` |
| + Error struct { |
| + Message string `json:"message"` |
| + } `json:"error"` |
| + } |
| + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&sub); err != nil { |
| + return false, fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode) |
| + } |
| + if resp.StatusCode != http.StatusOK || len(sub.Items.Data) == 0 { |
| + return false, fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, sub.Error.Message) |
| + } |
| + item := sub.Items.Data[0] |
| + if item.Quantity == int64(seats) { |
| + return false, nil |
| + } |
| + form := url.Values{} |
| + form.Set("items[0][id]", item.ID) |
| + form.Set("items[0][quantity]", strconv.Itoa(seats)) |
| + form.Set("proration_behavior", "none") |
| + upd, err := http.NewRequest("POST", b.apiBase+"/v1/subscriptions/"+subscriptionID, strings.NewReader(form.Encode())) |
| + if err != nil { |
| + return false, err |
| + } |
| + upd.Header.Set("Authorization", "Bearer "+b.key) |
| + upd.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + resp2, err := b.client.Do(upd) |
| + if err != nil { |
| + return false, err |
| + } |
| + defer resp2.Body.Close() |
| + if resp2.StatusCode != http.StatusOK { |
| + var body struct { |
| + Error struct { |
| + Message string `json:"message"` |
| + } `json:"error"` |
| + } |
| + _ = json.NewDecoder(io.LimitReader(resp2.Body, 1<<20)).Decode(&body) |
| + return false, fmt.Errorf("stripe: HTTP %d: %s", resp2.StatusCode, body.Error.Message) |
| + } |
| + return true, nil |
| +} |
| + |
| // ---- webhook ------------------------------------------------------------------- |
| // stripeSigTolerance bounds how stale a webhook's signed timestamp may be — |
| @@ -894,6 +987,7 @@ func (s *cloudServer) handleSubscriptionEvent(evType string, raw json.RawMessage |
| Items struct { |
| Data []struct { |
| CurrentPeriodEnd int64 `json:"current_period_end"` |
| + Quantity int64 `json:"quantity"` |
| Price struct { |
| ID string `json:"id"` |
| } `json:"price"` |
| @@ -930,18 +1024,25 @@ func (s *cloudServer) handleSubscriptionEvent(evType string, raw json.RawMessage |
| // metadata, so the price is the truth and metadata only ever seeded the |
| // first value. |
| tier, band := "", "" |
| + seats := 0 |
| for _, item := range obj.Items.Data { |
| if item.CurrentPeriodEnd > paidUntil { |
| paidUntil = item.CurrentPeriodEnd |
| } |
| if t, b, ok := s.billing.tierBandByPrice(item.Price.ID); ok { |
| tier, band = t.Key, b |
| + // The billed head count, from Stripe's own item — the ledger's |
| + // seats always mirror what the subscription actually charges |
| + // (0 on flat tiers; the true-up compares against this). |
| + if t.PerSeat { |
| + seats = int(item.Quantity) |
| + } |
| } |
| } |
| - switch err := s.db.setEntitlementTier(id, ent, tier, band, paidUntil, obj.CancelAtPeriodEnd); err { |
| + switch err := s.db.setEntitlementTier(id, ent, tier, band, seats, paidUntil, obj.CancelAtPeriodEnd); err { |
| case nil: |
| - log.Printf("cloud: space id %d entitlement → %s (tier %s, band %s, subscription %s, cancelling=%v)", id, ent, tier, band, obj.Status, obj.CancelAtPeriodEnd) |
| + log.Printf("cloud: space id %d entitlement → %s (tier %s, band %s, seats %d, subscription %s, cancelling=%v)", id, ent, tier, band, seats, obj.Status, obj.CancelAtPeriodEnd) |
| s.pushQuotaByID(id) // entitlement/tier moved — re-apply the matching cap |
| s.pushReplicationByID(id) // and the matching durability rung (lapse = back to free's) |
| return nil |
diff --git a/changelog/2026-07-22-business-per-seat.md b/changelog/2026-07-22-business-per-seat.md| new file mode 100644 |
| index 0000000..840d2fd |
| --- /dev/null |
| +++ b/changelog/2026-07-22-business-per-seat.md |
| @@ -0,0 +1,6 @@ |
| +🤖 Business spaces now price by team size: €11 per person per year, annual |
| +only, with 11 GB of shared storage per person. A seat is a person — helpers |
| +and bots are free — counted automatically, never self-reported. Your price |
| +adjusts to your team's size at each annual renewal: if people left, you renew |
| +cheaper; if the team grew, the renewal covers them. Nothing is charged or |
| +credited mid-year. |
diff --git a/cloud.go b/cloud.go| index f90e177..81ba8e6 100644 |
| --- a/cloud.go |
| +++ b/cloud.go |
| @@ -108,6 +108,10 @@ type cloudServer struct { |
| // billing's so it can run against the sandbox; nil = off. |
| enterprise *enterpriseCheckout |
| storageCaps map[string]int64 // tier key (+ "free") → storage cap pushed to instances; empty = no enforcement |
| + // seatCaps holds the per-seat entries of LCHAT_CLOUD_STORAGE_CAPS |
| + // ("business=11GB/seat"): cap = value × the space's live head count, |
| + // pooled across the space (docs/tier-economics.md § Per-seat). |
| + seatCaps map[string]int64 |
| // replicationClasses maps tier keys (+ "free") to durability-ladder classes |
| // (cloudreplication.go); empty = classes never pushed, every space streams. |
| replicationClasses map[string]string |
| @@ -352,7 +356,7 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request) |
| log.Printf("cloud: ledger finalize for %s (id %d) failed: %v", host, id, err) |
| } |
| log.Printf("cloud: created %s (id %d, caller %.8s…)", host, id, callerID) |
| - s.pushQuota(id, host, entFree, "") // insurance cap for a fresh free space (no-op unless caps configured) |
| + s.pushQuota(id, host, entFree, "", 0) // insurance cap for a fresh free space (no-op unless caps configured) |
| if s.usageURL != "" { |
| // Meter token from first boot (#858): the usage pipe AND the AI |
| // opt-in channel — a fresh space's onboarding opt-in must not |
| @@ -663,7 +667,7 @@ func runCloud(args []string) { |
| if err != nil { |
| die("cloud: id ledger: %v", err) |
| } |
| - storageCaps, err := parseStorageCaps(os.Getenv("LCHAT_CLOUD_STORAGE_CAPS")) |
| + storageCaps, seatCaps, err := parseStorageCaps(os.Getenv("LCHAT_CLOUD_STORAGE_CAPS")) |
| if err != nil { |
| die("cloud: LCHAT_CLOUD_STORAGE_CAPS: %v", err) |
| } |
| @@ -720,6 +724,7 @@ func runCloud(args []string) { |
| billing: billing, |
| enterprise: enterprise, |
| storageCaps: storageCaps, |
| + seatCaps: seatCaps, |
| replicationClasses: replicationClasses, |
| createBudget: *createBudget, |
| demoTTL: time.Duration(*demoTTLHours) * time.Hour, |
| @@ -775,8 +780,8 @@ func runCloud(args []string) { |
| } |
| } |
| } |
| - if len(storageCaps) > 0 { |
| - log.Printf("cloud: storage caps on (%d entries)", len(storageCaps)) |
| + if len(storageCaps)+len(seatCaps) > 0 { |
| + log.Printf("cloud: storage caps on (%d flat, %d per-seat)", len(storageCaps), len(seatCaps)) |
| } |
| if len(replicationClasses) > 0 { |
| log.Printf("cloud: replication classes on (%d entries)", len(replicationClasses)) |
diff --git a/cloud_bands_test.go b/cloud_bands_test.go| index 03dc10a..08ac333 100644 |
| --- a/cloud_bands_test.go |
| +++ b/cloud_bands_test.go |
| @@ -300,17 +300,17 @@ func TestPublicTiersBands(t *testing.T) { |
| func TestNetForTierBand(t *testing.T) { |
| _, s, _ := billingTestCloud(t) |
| withBands(s) |
| - if net := s.netForTier("friends", "base"); net != 44 { |
| + if net := s.netForTier("friends", "base", 0); net != 44 { |
| t.Fatalf("base net = %v", net) |
| } |
| - if net := s.netForTier("friends", ""); net != 44 { |
| + 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"); net != 21 { |
| + 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"); net != 44 { |
| + if net := s.netForTier("friends", "supporter", 0); net != 44 { |
| t.Fatalf("retired-band net = %v", net) |
| } |
| } |
diff --git a/cloud_billing_test.go b/cloud_billing_test.go| index 9fa5b60..11ddd9a 100644 |
| --- a/cloud_billing_test.go |
| +++ b/cloud_billing_test.go |
| @@ -985,7 +985,7 @@ func TestDestroySkipsStripeWhenNotPaid(t *testing.T) { |
| // re-cancelling a canceled subscription is a Stripe error, so the gate is |
| // the entitlement, not the id. |
| host2, id2 := paidSpace(t, srv, s, tok) |
| - if err := s.db.setEntitlementTier(id2, entFree, "", "", 0, false); err != nil { |
| + if err := s.db.setEntitlementTier(id2, entFree, "", "", 0, 0, false); err != nil { |
| t.Fatal(err) |
| } |
| if r := deleteSpaceReq(t, srv, tok, host2, "user-1"); r.StatusCode != http.StatusOK { |
diff --git a/cloud_db.go b/cloud_db.go| index a86faa5..494554d 100644 |
| --- a/cloud_db.go |
| +++ b/cloud_db.go |
| @@ -327,6 +327,17 @@ ALTER TABLE spaces ADD COLUMN paid_until INTEGER NOT NULL DEFAULT 0;`); err != n |
| return err |
| } |
| } |
| + // seats: the head count a per-seat subscription currently bills, mirrored |
| + // from Stripe's own subscription item (docs/tier-economics.md § Per-seat). |
| + // 0 on flat tiers and pre-seat rows; the renewal true-up compares it |
| + // against the live member count. |
| + if has, err := db.spacesHasColumn("seats"); err != nil { |
| + return err |
| + } else if !has { |
| + if _, err := db.sql.Exec(`ALTER TABLE spaces ADD COLUMN seats INTEGER NOT NULL DEFAULT 0`); err != nil { |
| + return err |
| + } |
| + } |
| // Pay-before-create (the tier picker in the create flow): the Checkout |
| // Session bound to a pending allocation, and the name chosen at create — |
| // held here (never in Stripe metadata) until the claim provisions it. |
| @@ -802,16 +813,17 @@ func (db *cloudDB) markSpacePaid(id int64, customerID, subscriptionID, tier, ban |
| // setEntitlement flips a space's entitlement + subscription period end. |
| func (db *cloudDB) setEntitlement(id int64, ent string, paidUntil int64) error { |
| - return db.setEntitlementTier(id, ent, "", "", paidUntil, false) |
| + return db.setEntitlementTier(id, ent, "", "", 0, paidUntil, false) |
| } |
| // setEntitlementTier additionally moves the tier + band when known ("" keeps |
| -// the recorded value — e.g. an event without price data) and records whether |
| -// the subscription is set to run out at paid_until. |
| -func (db *cloudDB) setEntitlementTier(id int64, ent, tier, band string, paidUntil int64, cancelAtEnd bool) error { |
| +// the recorded value — e.g. an event without price data), mirrors the billed |
| +// seat count (0 keeps the recorded value, same rationale), and records |
| +// whether the subscription is set to run out at paid_until. |
| +func (db *cloudDB) setEntitlementTier(id int64, ent, tier, band string, seats int, paidUntil int64, cancelAtEnd bool) error { |
| res, err := db.sql.Exec( |
| - `UPDATE spaces SET entitlement = ?, paid_until = ?, tier = COALESCE(NULLIF(?, ''), tier), band = COALESCE(NULLIF(?, ''), band), cancel_at_period_end = ? WHERE id = ?`, |
| - ent, paidUntil, tier, band, cancelAtEnd, id, |
| + `UPDATE spaces SET entitlement = ?, paid_until = ?, tier = COALESCE(NULLIF(?, ''), tier), band = COALESCE(NULLIF(?, ''), band), seats = COALESCE(NULLIF(?, 0), seats), cancel_at_period_end = ? WHERE id = ?`, |
| + ent, paidUntil, tier, band, seats, cancelAtEnd, id, |
| ) |
| if err != nil { |
| return err |
| @@ -823,7 +835,8 @@ func (db *cloudDB) setEntitlementTier(id int64, ent, tier, band string, paidUnti |
| } |
| // meterRow is one space in the meter's sweep set — enough billing state to |
| -// reconcile its quota alongside the byte reading. |
| +// reconcile its quota alongside the byte reading, plus what the per-seat |
| +// renewal true-up needs (subscription id, billed seats, renewal time). |
| type meterRow struct { |
| ID int64 |
| Host string |
| @@ -831,12 +844,16 @@ type meterRow struct { |
| Tier string |
| QuotaBytes int64 |
| MeterTokenHash string |
| + Seats int |
| + PaidUntil int64 |
| + SubscriptionID string |
| + Members int // last metered COUNT(users) — the stats-down fallback for per-seat caps |
| } |
| // allFinalizedSpaces is the meter's sweep set: every space with a hostname. |
| func (db *cloudDB) allFinalizedSpaces() ([]meterRow, error) { |
| rows, err := db.sql.Query( |
| - `SELECT id, host, entitlement, tier, quota_bytes, meter_token_hash FROM spaces WHERE host IS NOT NULL ORDER BY id`) |
| + `SELECT id, host, entitlement, tier, quota_bytes, meter_token_hash, seats, paid_until, COALESCE(stripe_subscription_id, ''), members FROM spaces WHERE host IS NOT NULL ORDER BY id`) |
| if err != nil { |
| return nil, err |
| } |
| @@ -844,7 +861,7 @@ func (db *cloudDB) allFinalizedSpaces() ([]meterRow, error) { |
| var out []meterRow |
| for rows.Next() { |
| var m meterRow |
| - if err := rows.Scan(&m.ID, &m.Host, &m.Entitlement, &m.Tier, &m.QuotaBytes, &m.MeterTokenHash); err != nil { |
| + if err := rows.Scan(&m.ID, &m.Host, &m.Entitlement, &m.Tier, &m.QuotaBytes, &m.MeterTokenHash, &m.Seats, &m.PaidUntil, &m.SubscriptionID, &m.Members); err != nil { |
| return nil, err |
| } |
| out = append(out, m) |
| @@ -876,7 +893,7 @@ CREATE TABLE IF NOT EXISTS pool_meter ( |
| // doesn't know the price book; the sweep resolves it. |
| func (db *cloudDB) poolRows() ([]poolRow, error) { |
| rows, err := db.sql.Query( |
| - `SELECT entitlement, tier, band, storage_bytes FROM spaces WHERE host IS NOT NULL`) |
| + `SELECT entitlement, tier, band, seats, storage_bytes FROM spaces WHERE host IS NOT NULL`) |
| if err != nil { |
| return nil, err |
| } |
| @@ -884,7 +901,7 @@ func (db *cloudDB) poolRows() ([]poolRow, error) { |
| var out []poolRow |
| for rows.Next() { |
| var r poolRow |
| - if err := rows.Scan(&r.Entitlement, &r.Tier, &r.Band, &r.StorageBytes); err != nil { |
| + if err := rows.Scan(&r.Entitlement, &r.Tier, &r.Band, &r.Seats, &r.StorageBytes); err != nil { |
| return nil, err |
| } |
| out = append(out, r) |
| @@ -945,8 +962,8 @@ func (db *cloudDB) latestPoolMeter() (ts int64, r poolReading, ok bool, err erro |
| func (db *cloudDB) spaceBillingByID(id int64) (m meterRow, err error) { |
| m.ID = id |
| err = db.sql.QueryRow( |
| - `SELECT COALESCE(host,''), entitlement, tier, quota_bytes FROM spaces WHERE id = ?`, id, |
| - ).Scan(&m.Host, &m.Entitlement, &m.Tier, &m.QuotaBytes) |
| + `SELECT COALESCE(host,''), entitlement, tier, quota_bytes, seats, members FROM spaces WHERE id = ?`, id, |
| + ).Scan(&m.Host, &m.Entitlement, &m.Tier, &m.QuotaBytes, &m.Seats, &m.Members) |
| return m, err |
| } |
diff --git a/cloud_perseat_test.go b/cloud_perseat_test.go| new file mode 100644 |
| index 0000000..399adc0 |
| --- /dev/null |
| +++ b/cloud_perseat_test.go |
| @@ -0,0 +1,373 @@ |
| +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}, |
| + } |
| +} |
| + |
| +// 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) |
| + } |
| +} |
diff --git a/controld.go b/controld.go| index 6181434..1ee5273 100644 |
| --- a/controld.go |
| +++ b/controld.go |
| @@ -418,7 +418,7 @@ func (c *controld) statsHandler(w http.ResponseWriter, r *http.Request) { |
| writeControlErr(w, http.StatusBadRequest, "invalid_host", "not a valid hostname") |
| return |
| } |
| - members, setupComplete, hasContent, err := instanceStatus(host) |
| + members, people, setupComplete, hasContent, err := instanceStatus(host) |
| if err != nil { |
| if os.IsNotExist(err) { |
| // A hibernating space that has never woken has no local DB at all — |
| @@ -433,7 +433,7 @@ func (c *controld) statsHandler(w http.ResponseWriter, r *http.Request) { |
| // skips those, as before.) |
| if c.reg != nil { |
| if rt, rerr := c.reg.get(host); rerr == nil && rt != nil && rt.Kind == "instance" && rt.Hibernate { |
| - writeJSON(w, http.StatusOK, map[string]any{"host": host, "members": 0, "setup_complete": false, "has_content": false, "storage_bytes": 0}) |
| + writeJSON(w, http.StatusOK, map[string]any{"host": host, "members": 0, "people": 0, "setup_complete": false, "has_content": false, "storage_bytes": 0}) |
| return |
| } |
| } |
| @@ -445,7 +445,7 @@ func (c *controld) statsHandler(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| writeJSON(w, http.StatusOK, map[string]any{ |
| - "host": host, "members": members, "setup_complete": setupComplete, "has_content": hasContent, |
| + "host": host, "members": members, "people": people, "setup_complete": setupComplete, "has_content": hasContent, |
| // The storage meter's read (meter.go) — pure filesystem metadata, |
| // measured alongside the DB stats so one endpoint serves both sweeps. |
| "storage_bytes": instanceStorageBytes(host), |
diff --git a/demo.go b/demo.go| index eeacf03..2644e0b 100644 |
| --- a/demo.go |
| +++ b/demo.go |
| @@ -88,7 +88,7 @@ func (s *cloudServer) createDemoSpace(ctx context.Context, w http.ResponseWriter |
| log.Printf("cloud: ledger finalize for demo %s (id %d) failed: %v", host, id, err) |
| } |
| log.Printf("cloud: created demo %s (id %d, caller %.8s…, dies in %s)", host, id, callerID, s.demoTTL) |
| - s.pushQuota(id, host, entDemo, "") // the tiny demo cap (no-op unless configured) |
| + s.pushQuota(id, host, entDemo, "", 0) // the tiny demo cap (no-op unless configured) |
| writeJSON(w, http.StatusCreated, map[string]any{ |
| "host": host, |
| "url": body["url"], |
| @@ -247,7 +247,7 @@ func (s *cloudServer) claimDemoHandler(w http.ResponseWriter, r *http.Request) { |
| // cap, and record the free replication class for when the space is later |
| // flipped onto the activator (hibernation/backups are an operator or |
| // follow-up concern — the claim itself never restarts anything). |
| - s.pushQuota(id, host, entFree, "") |
| + s.pushQuota(id, host, entFree, "", 0) |
| s.pushReplication(host, entFree, "") |
| writeJSON(w, http.StatusOK, map[string]any{ |
| "host": host, "url": "https://" + host, "entitlement": entFree, |
diff --git a/demo_test.go b/demo_test.go| index 56b0793..05a5a85 100644 |
| --- a/demo_test.go |
| +++ b/demo_test.go |
| @@ -217,11 +217,11 @@ func TestMainReaperIgnoresDemos(t *testing.T) { |
| func TestCapForDemo(t *testing.T) { |
| s := &cloudServer{storageCaps: map[string]int64{"free": 2e9, "demo": 25e6}} |
| - if got := s.capFor(entDemo, ""); got != 25e6 { |
| + if got := s.capFor(entDemo, "", 0); got != 25e6 { |
| t.Fatalf("demo cap = %d, want its own 25MB entry", got) |
| } |
| s.storageCaps = map[string]int64{"free": 2e9} |
| - if got := s.capFor(entDemo, ""); got != 2e9 { |
| + if got := s.capFor(entDemo, "", 0); got != 2e9 { |
| t.Fatalf("demo cap without a demo entry = %d, want the free fallback", got) |
| } |
| } |
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md| index 6454b14..ea1742a 100644 |
| --- a/docs/cloud-provisioner.md |
| +++ b/docs/cloud-provisioner.md |
| @@ -435,8 +435,9 @@ it. Unset = no enforcement, and operator-run instances (not in this ledger) |
| are never touched. Caps follow the 80/20 margin |
| rule in `tier-economics.md` (an allowance is what 20% of a tier's net buys |
| in storage, forever) and always match the published allowance table. |
| -Business's 1 TB exceeds the box's disk — honored on demand (attach a Hetzner |
| -volume) until attachments move off-box (#550). |
| +Business is per-seat (`business=11GB/seat` — cap = 11 GB × the space's live |
| +head count, pooled); a big team's pool exceeds the box's disk — honored on |
| +demand (attach a Hetzner volume) until attachments move off-box (#550). |
| ## The usage pipe (#857, #668 slices 2–3) — AI usage into the ledger |
diff --git a/docs/pricing-bands.md b/docs/pricing-bands.md| index 02a0d5e..eded3f8 100644 |
| --- a/docs/pricing-bands.md |
| +++ b/docs/pricing-bands.md |
| @@ -28,8 +28,7 @@ Multipliers on the existing EUR base prices, charm-rounded: |
| | Family (annual) | €7/y | €5/y | €2.50/y | €1.25/y | |
| | C&F monthly | €5.50/m | €4/m | €2/m | — | |
| | C&F annual | €56/y | €40/y | €20/y | €10/y | |
| -| Business monthly | €139/m | €99/m | €49/m | €25/m | |
| -| Business annual | €1,399/y | €999/y | €499/y | €249/y | |
| +| Business (annual, per seat) | €15/person/y | €11/person/y | €5.50/person/y | — | |
| - **Family and C&F prices are VAT-inclusive; Business prices are |
| VAT-exclusive** (decided 2026-07-14) — the standard B2C/B2B split: a |
| @@ -38,6 +37,11 @@ Multipliers on the existing EUR base prices, charm-rounded: |
| - **Solidarity is annual-only**: Stripe's fixed ~€0.25/charge makes €1/m |
| fee-hostile (~26% of gross), and annual matches how those buyers think |
| about €10. |
| +- **Business went per-seat 2026-07-22** (€11/person/year, annual only — |
| + `tier-economics.md § Per-seat`): the band multipliers apply per seat, and |
| + Solidarity is dropped for Business — a solidarity price aimed at |
| + hardship-priced *businesses* never quite parsed, and Reduced at |
| + €5.50/person covers nonprofits/education cleanly. |
| - **All bands are always visible and any is selectable** — adjacent-only |
| stepping was considered and rejected; friction is the enemy of the story. |
| - No band is structurally loss-making while hibernation holds (even |
| @@ -284,7 +288,8 @@ Business prices are VAT-exclusive** — the standard B2C/B2B convention. |
| - **Bookkeeping:** consumer-tier `net_eur_year` figures become net-of-VAT |
| approximations across buyer countries (the free pool tolerates that |
| comfortably); Business `net_eur_year` is *more* stable than today — |
| - €999 is €999 regardless of buyer country. |
| + €11/seat is €11/seat regardless of buyer country (per-seat since |
| + 2026-07-22, so the pool input scales it by the billed seats). |
| - **Caveat:** ex-VAT display is standard for business-directed offers |
| (Slack/Notion-style), but EU price-indication rules technically demand |
| consumer-facing totals be inclusive — so the Business tier should present |
diff --git a/docs/tier-economics.md b/docs/tier-economics.md| index 959ea72..8116096 100644 |
| --- a/docs/tier-economics.md |
| +++ b/docs/tier-economics.md |
| @@ -29,18 +29,53 @@ Living doc: the *formulas* are the contract; the *numbers* are a snapshot |
| marginal cost that can dwarf a subscription; bot/AI usage is always opt-in, |
| and when opted into it's a metered add-on (design in #562). |
| -## The price book (as of 2026-07-11) |
| +## The price book (as of 2026-07-22) |
| | Tier | Price | Billing | |
| |---|---|---| |
| | Family | €5/year | annual only | |
| | Community & Friends | €4/month or €40/year | toggle | |
| -| Business | €99/month or €999/year | toggle | |
| +| Business | €11 per person per year | annual only, per seat | |
| What paying buys today: the space stops counting toward the account's |
| three-space creation limit, it is never reclaimed as unused, and it carries a |
| larger storage allowance (below). |
| +## Per-seat Business (decided 2026-07-22) |
| + |
| +Business flipped from flat (€99/month / €999/year) to **€11 per person per |
| +year** — the price scales with the team instead of gating at a size the |
| +space has to grow into. The crossover with the old flat price was ~91 people, |
| +so a small business pays a fraction of what it used to and a big one |
| +continuously *becomes* the old plan. Mechanics, all held by the provisioner |
| +(`per_seat` in the price book; billing.go, meter.go): |
| + |
| +- **Seats are counted server-side, never self-declared.** A seat is a |
| + non-agent member (`people` in controld's stats) — bots are free. Checkout |
| + charges the live count as the Stripe line-item quantity; the ledger mirrors |
| + whatever the subscription actually bills (`spaces.seats`, from Stripe's own |
| + item via webhook). |
| +- **Dropoffs and growth settle at renewal, never mid-cycle.** In the week |
| + before each annual renewal the meter sweep re-counts and sets the |
| + subscription quantity with `proration_behavior=none`: shrinkage renews |
| + cheaper, growth renews dearer, and nobody sees a mid-year €33 refund or |
| + surprise top-up. That one Stripe knob is the entire dropoff policy. |
| +- **Annual only.** €11/12 ≈ €0.92/person/month; Stripe's fixed €0.25 per |
| + charge makes tiny monthly invoices ~7%-fee territory and 12× the dunning |
| + surface. Same reasoning as Family's annual-only, now applied where the |
| + per-charge ticket is small. |
| +- **The allowance is per seat and pooled**: 11 GB × seats, one shared pot, |
| + so a heavy channel never hits an individual wall. Derivation per seat: |
| + net ≈ €10.82, the 80/20 reserve takes €8.80, leaving ~€2.00 ÷ |
| + €0.168/GB-year ≈ 12 GB — published as 11 (the elevens are load-bearing). |
| + At ~91 seats the pool crosses 1 TB exactly where revenue crosses the old |
| + €999 — the tables stay coherent. |
| +- **Small teams run thinner, and that's accepted.** Under ~10 seats the |
| + fixed per-space costs (resident slot, the `stream` replication rung) eat |
| + past the 20% line — a 3-person space runs maybe 50–60% margin, not 80%+. |
| + Storage is still capped per seat, so no team size is structurally |
| + loss-making; tiny businesses grow. |
| + |
| ## Storage allowances (as of 2026-07-14 — margin-derived) |
| Space creation is also gated by an account quota (3 free spaces; paid spaces |
| @@ -55,8 +90,7 @@ keeping ≥80% of gross as profit — |
| allowance_GB = (net − 0.80 × gross − compute_cost) ÷ (storage_rate × 12) |
| ``` |
| -— computed at the tighter (monthly) billing case (Business: 1,068 GB annual |
| -/ 1,259 GB monthly, published as 1 TB). Two honest exceptions: the Free tier |
| +— computed at the tighter billing case. Two honest exceptions: the Free tier |
| has no revenue (its 1 GB is pure cost containment), and Family at €5/year |
| buys only ~3 GB at strict 80/20 — we hold it at 5 GB and accept a ~74% |
| worst-case floor rather than shrink the family tier. |
| @@ -66,10 +100,11 @@ worst-case floor rather than shrink the family tier. |
| | Free | 1 GB | |
| | Family | 5 GB | |
| | Community & Friends | 20 GB | |
| -| Business | 1 TB | |
| +| Business | 11 GB per person (pooled) | |
| -The allowances ARE the enforced caps (`LCHAT_CLOUD_STORAGE_CAPS`) — the |
| -published table and the config must always match. Business's 1 TB |
| +The allowances ARE the enforced caps (`LCHAT_CLOUD_STORAGE_CAPS` — per-seat |
| +entries use the `/seat` suffix, e.g. `business=11GB/seat`) — the published |
| +table and the config must always match. A large team's pooled allowance |
| deliberately exceeds the shared machine's ~38 GB disk: the promise is |
| honored **on demand** — a Business space genuinely growing toward its |
| allowance gets a Hetzner volume attached (minutes, up to 10 TB per volume) |
| @@ -134,7 +169,9 @@ floor is the real day-to-day cadence on the daily rung. |
| **One Business plan, not a "Business Plus" (decided 2026-07-14).** The full |
| resilience stack — streaming replication, the retention ladder behind |
| point-in-time restore, room to tighten the crash-failover window — costs |
| -€2–20/year on realistic Business spaces against €999–1,188/year of revenue. |
| +€2–20/year on realistic Business spaces against revenue that scales with the |
| +team (per-seat since 2026-07-22: €330/year at 30 people; the decision was |
| +taken against the flat €999–1,188/year and holds at per-seat scale). |
| A resilience delta that small can't carry a second paid tier: splitting it |
| would add a Stripe price pair, picker copy, and a matrix cell to explain, |
| to sell something we can afford to include. So Business simply *includes* |
| @@ -162,17 +199,18 @@ break_even_GB = net ÷ (storage_rate × 12) # where a space stops coveri |
| Message *text* is never material: a decade of busy family chat is ~50 MB |
| (~€0.01/year). The only way a space outspends its price is stored media. |
| -## Estimates per tier (snapshot, 2026-07-14) |
| +## Estimates per tier (snapshot, 2026-07-22) |
| -Usage profiles are typical-case estimates, not caps: |
| +Usage profiles are typical-case estimates, not caps. Business is per-seat, so |
| +it gets two team sizes — the structure holds at both: |
| | Tier | Gross/yr | Net/yr | Profile (est.) | Cost/yr (est.) | Profit/yr (est.) | Margin | Break-even storage | |
| |---|---|---|---|---|---|---|---| |
| | Family €5/y | €5.00 | €4.68 | 10 members, 5 GB, mostly asleep | ~€1.00 | ~€3.70 | ~74% | ~28 GB | |
| | C&F €40/y | €40.00 | €39.15 | 40 members, 20 GB, awake ~20% | ~€5.50 | ~€33.70 | ~84% | ~230 GB | |
| | C&F €4/m | €48.00 | €44.28 | same | ~€5.50 | ~€38.80 | ~81% | ~265 GB | |
| -| Business €999/y | €999.00 | €983.77 | 150 members, 100 GB, awake ~50% | ~€22.00 | ~€962 | ~96% | ~5.9 TB | |
| -| Business €99/m | €1,188.00 | €1,167.18 | same | ~€22.00 | ~€1,145 | ~96% | ~7 TB | |
| +| Business €11/p/y | €330.00 | €324.80 | 30 people, 60 GB pooled, awake ~50% | ~€20.00 | ~€305 | ~92% | ~1.9 TB | |
| +| Business €11/p/y | €1,650.00 | €1,625.00 | 150 people, 300 GB pooled, awake ~50% | ~€75.00 | ~€1,550 | ~94% | ~9.7 TB | |
| Readings: |
| @@ -190,10 +228,11 @@ Readings: |
| Family spaces, or a dozen C&F annuals, or one Business space cover it |
| outright. Beyond that, margin is contribution toward resilience and the |
| co-operative endgame (#550 Part 10). |
| -- **Business margin is deliberately not an infra story.** At ~96% margin the |
| - price is buying priority support, SLAs, and features to come — not bytes. |
| - If that feels wrong when the books go public, that's the conversation the |
| - books exist to force. |
| +- **Business margin is deliberately not an infra story.** At ~92–94% margin |
| + on realistic teams the price is buying priority support, SLAs, and features |
| + to come — not bytes. (Small teams run thinner — see § Per-seat.) If that |
| + feels wrong when the books go public, that's the conversation the books |
| + exist to force. |
| ## What could invert these numbers |
diff --git a/meter.go b/meter.go| index 35b39dc..1658fd7 100644 |
| --- a/meter.go |
| +++ b/meter.go |
| @@ -111,9 +111,12 @@ func (s *cloudServer) meterOnce() (metered int) { |
| continue |
| } |
| metered++ |
| - if want := s.capFor(sp.Entitlement, sp.Tier); want > 0 && want != sp.QuotaBytes { |
| - s.pushQuota(sp.ID, sp.Host, sp.Entitlement, sp.Tier) |
| + if want := s.capFor(sp.Entitlement, sp.Tier, st.People); want > 0 && want != sp.QuotaBytes { |
| + s.pushQuota(sp.ID, sp.Host, sp.Entitlement, sp.Tier, st.People) |
| } |
| + // The per-seat renewal true-up rides the same sweep: in the runway |
| + // before a per-seat renewal, re-count and set next year's quantity. |
| + s.reconcileSeats(sp, st.People, now) |
| // The usage pipe (#857): any space without a meter token gets one — |
| // how the fleet converges without a per-space backfill step. |
| if s.usageURL != "" && sp.MeterTokenHash == "" { |
| @@ -165,33 +168,44 @@ func parseByteSize(v string) (int64, error) { |
| } |
| // parseStorageCaps reads LCHAT_CLOUD_STORAGE_CAPS: "free=10GB,family=25GB,…" — |
| -// keys are tier keys from the price book, plus "free" for unpaid spaces. |
| -func parseStorageCaps(v string) (map[string]int64, error) { |
| +// keys are tier keys from the price book, plus "free" for unpaid spaces. A |
| +// "/seat" suffix on the size ("business=11GB/seat") makes the entry per-seat: |
| +// the pushed cap is that size × the space's live head count (pooled — one |
| +// shared pot, no per-member walls; docs/tier-economics.md § Per-seat). |
| +func parseStorageCaps(v string) (caps, seatCaps map[string]int64, err error) { |
| if strings.TrimSpace(v) == "" { |
| - return nil, nil |
| + return nil, nil, nil |
| } |
| - caps := map[string]int64{} |
| + caps, seatCaps = map[string]int64{}, map[string]int64{} |
| for _, pair := range strings.Split(v, ",") { |
| k, val, ok := strings.Cut(strings.TrimSpace(pair), "=") |
| if !ok || strings.TrimSpace(k) == "" { |
| - return nil, fmt.Errorf("bad entry %q (want key=size)", pair) |
| + return nil, nil, fmt.Errorf("bad entry %q (want key=size)", pair) |
| + } |
| + into := caps |
| + if rest, perSeat := strings.CutSuffix(strings.TrimSpace(val), "/seat"); perSeat { |
| + val, into = rest, seatCaps |
| } |
| n, err := parseByteSize(val) |
| if err != nil { |
| - return nil, fmt.Errorf("cap %q: %v", k, err) |
| + return nil, nil, fmt.Errorf("cap %q: %v", k, err) |
| } |
| - caps[strings.TrimSpace(k)] = n |
| + into[strings.TrimSpace(k)] = n |
| } |
| - return caps, nil |
| + return caps, seatCaps, nil |
| } |
| -// capFor maps a space's billing state to its storage cap. 0 = no cap to push |
| +// capFor maps a space's billing state to its storage cap. people is the live |
| +// non-agent member count, which only per-seat entries use. 0 = no cap to push |
| // (caps unconfigured, or a paid tier with no entry — never guess a limit). |
| -func (s *cloudServer) capFor(entitlement, tier string) int64 { |
| - if len(s.storageCaps) == 0 { |
| +func (s *cloudServer) capFor(entitlement, tier string, people int) int64 { |
| + if len(s.storageCaps) == 0 && len(s.seatCaps) == 0 { |
| return 0 |
| } |
| if entitlement == entPaid { |
| + if per, ok := s.seatCaps[tier]; ok { |
| + return per * int64(max(people, 1)) |
| + } |
| return s.storageCaps[tier] |
| } |
| // Demos get their own (tiny) cap when configured, else the free one — |
| @@ -227,8 +241,8 @@ func (s *cloudServer) controlSetQuota(host string, quota int64, entitlement stri |
| // pushQuota applies a space's tier cap to its instance, recording what was |
| // pushed. Best-effort by design: a failed push logs and stands down — the |
| // meter sweep reconciles it on the next pass. |
| -func (s *cloudServer) pushQuota(id int64, host, entitlement, tier string) { |
| - cap := s.capFor(entitlement, tier) |
| +func (s *cloudServer) pushQuota(id int64, host, entitlement, tier string, people int) { |
| + cap := s.capFor(entitlement, tier, people) |
| if cap <= 0 { |
| return |
| } |
| @@ -311,12 +325,59 @@ func setInstanceMeter(envPath, url, token string) error { |
| // pushQuotaByID looks a space's billing state up and applies its cap — |
| // the webhook-side entry point (events carry ids, not rows). |
| func (s *cloudServer) pushQuotaByID(id int64) { |
| - if len(s.storageCaps) == 0 { |
| + if len(s.storageCaps) == 0 && len(s.seatCaps) == 0 { |
| return |
| } |
| m, err := s.db.spaceBillingByID(id) |
| if err != nil || m.Host == "" { |
| return // pending create or gone — the sweep reconciles survivors |
| } |
| - s.pushQuota(id, m.Host, m.Entitlement, m.Tier) |
| + // Per-seat caps want the live head count; the last metered member count |
| + // (agents included — slightly generous, never tight) covers a stats |
| + // outage, and the sweep reconciles either way. |
| + people := m.Members |
| + if _, perSeat := s.seatCaps[m.Tier]; perSeat && m.Entitlement == entPaid { |
| + if st, err := s.controlStats(m.Host); err == nil { |
| + people = st.People |
| + } |
| + } |
| + s.pushQuota(id, m.Host, m.Entitlement, m.Tier, people) |
| +} |
| + |
| +// seatTrueUpWindow is how far before a per-seat renewal the sweep starts |
| +// reconciling the subscription's quantity with the live head count. A week of |
| +// hourly passes absorbs a missed webhook or a Stripe blip comfortably. |
| +const seatTrueUpWindow = 7 * 24 * time.Hour |
| + |
| +// reconcileSeats is the renewal true-up (docs/tier-economics.md § Per-seat): |
| +// inside the window before a per-seat space renews, set the subscription's |
| +// quantity to the live non-agent head count so the next invoice bills the |
| +// real team size. proration_behavior=none inside trueUpSeats means nothing is |
| +// charged or credited mid-cycle — this is the entire dropoff/growth policy. |
| +// The ledger's seats update when Stripe's subscription.updated webhook lands; |
| +// until then a repeat pass just re-reads the (now matching) quantity and |
| +// no-ops. |
| +func (s *cloudServer) reconcileSeats(sp meterRow, people int, now int64) { |
| + if s.billing == nil || sp.Entitlement != entPaid || sp.SubscriptionID == "" { |
| + return |
| + } |
| + t, ok := s.billing.tier(sp.Tier) |
| + if !ok || !t.PerSeat { |
| + return |
| + } |
| + if sp.PaidUntil <= now || sp.PaidUntil-now > int64(seatTrueUpWindow/time.Second) { |
| + return |
| + } |
| + want := max(people, 1) |
| + if want == sp.Seats { |
| + return |
| + } |
| + changed, err := s.billing.trueUpSeats(sp.SubscriptionID, want) |
| + if err != nil { |
| + log.Printf("cloud: seat true-up %s: %v (sweep will retry)", sp.Host, err) |
| + return |
| + } |
| + if changed { |
| + log.Printf("cloud: seats %s → %d at renewal (was %d)", sp.Host, want, sp.Seats) |
| + } |
| } |
diff --git a/meter_test.go b/meter_test.go| index 2be06b9..cceffa9 100644 |
| --- a/meter_test.go |
| +++ b/meter_test.go |
| @@ -106,14 +106,14 @@ func TestParseByteSizeAndCaps(t *testing.T) { |
| } |
| } |
| - caps, err := parseStorageCaps("free=10GB, family=25GB,business=2TB") |
| + caps, _, err := parseStorageCaps("free=10GB, family=25GB,business=2TB") |
| if err != nil || caps["free"] != 10e9 || caps["family"] != 25e9 || caps["business"] != 2e12 { |
| t.Fatalf("caps = %v, %v", caps, err) |
| } |
| - if c, err := parseStorageCaps(""); c != nil || err != nil { |
| + if c, sc, err := parseStorageCaps(""); c != nil || sc != nil || err != nil { |
| t.Errorf("empty caps = %v, %v; want nil, nil (enforcement off)", c, err) |
| } |
| - if _, err := parseStorageCaps("free=banana"); err == nil { |
| + if _, _, err := parseStorageCaps("free=banana"); err == nil { |
| t.Error("garbage cap accepted") |
| } |
| } |
diff --git a/pool.go b/pool.go| index e86ffc3..e18db24 100644 |
| --- a/pool.go |
| +++ b/pool.go |
| @@ -104,6 +104,7 @@ type poolRow struct { |
| Entitlement string |
| Tier string |
| Band string |
| + Seats int // billed head count on per-seat tiers; 0 on flat ones |
| StorageBytes int64 |
| NetEURYear float64 |
| } |
| @@ -202,7 +203,9 @@ func poolState(pressure float64) string { |
| // additive net_eur_year field (0 when billing is off or the entry has none). |
| // The band picks the honor price actually paid — a Reduced subscription must |
| // feed the pool budget its real net, not base's (docs/pricing-bands.md). |
| -func (s *cloudServer) netForTier(tier, band string) float64 { |
| +// On per-seat tiers net_eur_year is per seat, so the figure scales by the |
| +// billed head count (docs/tier-economics.md § Per-seat). |
| +func (s *cloudServer) netForTier(tier, band string, seats int) float64 { |
| if s.billing == nil { |
| return 0 |
| } |
| @@ -210,11 +213,15 @@ func (s *cloudServer) netForTier(tier, band string) float64 { |
| if !ok { |
| return 0 |
| } |
| - if _, net, ok := bandPrice(t, band); ok { |
| - return net |
| + net := t.NetEURYear |
| + if _, n, ok := bandPrice(t, band); ok { |
| + net = n |
| } |
| - // A recorded band the book no longer names: base's figure beats zero. |
| - return t.NetEURYear |
| + // else: a recorded band the book no longer names — base's figure beats zero. |
| + if t.PerSeat { |
| + net *= float64(max(seats, 1)) |
| + } |
| + return net |
| } |
| // poolSweep computes and records one gauge reading from the ledger. Called at |
| @@ -228,7 +235,7 @@ func (s *cloudServer) poolSweep(now time.Time) { |
| } |
| for i := range rows { |
| if rows[i].Entitlement == entPaid { |
| - rows[i].NetEURYear = s.netForTier(rows[i].Tier, rows[i].Band) |
| + rows[i].NetEURYear = s.netForTier(rows[i].Tier, rows[i].Band, rows[i].Seats) |
| } |
| } |
| r := computePool(s.pool, rows) |
diff --git a/reaper.go b/reaper.go| index ee34c15..790e8a8 100644 |
| --- a/reaper.go |
| +++ b/reaper.go |
| @@ -52,27 +52,37 @@ import ( |
| // finishes the setup conversation (markSetupComplete), then "1". members is |
| // returned too, as a belt-and-suspenders guard (don't reap if anyone beyond the |
| // seeded admin joined). |
| -func instanceStatus(host string) (members int, setupComplete, hasContent bool, err error) { |
| +func instanceStatus(host string) (members, people int, setupComplete, hasContent bool, err error) { |
| path := filepath.Join(instanceDataDir, host+".db") |
| if _, err := os.Stat(path); err != nil { |
| - return 0, false, false, err // not-exist propagates → the caller answers unknown_host |
| + return 0, 0, false, false, err // not-exist propagates → the caller answers unknown_host |
| } |
| conn, err := sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=busy_timeout(3000)") |
| if err != nil { |
| - return 0, false, false, err |
| + return 0, 0, false, false, err |
| } |
| defer conn.Close() |
| conn.SetMaxOpenConns(1) |
| ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| defer cancel() |
| if err := conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&members); err != nil { |
| - return 0, false, false, err |
| + return 0, 0, false, false, err |
| + } |
| + // people is the billable head count: members who aren't agents — per-seat |
| + // billing must never charge for Claude or Eleven. Like the tombstone note |
| + // below, this reads OTHER instances' DB files, which may predate is_agent; |
| + // a failed count falls back to the full member count rather than erroring |
| + // the whole stats read. |
| + people = members |
| + var np int |
| + if err := conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE COALESCE(is_agent, 0) = 0").Scan(&np); err == nil { |
| + people = np |
| } |
| var v string |
| switch err := conn.QueryRowContext(ctx, "SELECT value FROM config WHERE key='setup_complete'").Scan(&v); err { |
| case nil, sql.ErrNoRows: // absent = never set up |
| default: |
| - return 0, false, false, err |
| + return 0, 0, false, false, err |
| } |
| // hasContent guards against a false positive: any message or thread means the |
| // space was actually used, so it's never reaped no matter what setup_complete |
| @@ -84,9 +94,9 @@ func instanceStatus(host string) (members int, setupComplete, hasContent bool, e |
| var hasMsg, hasThread int |
| if err := conn.QueryRowContext(ctx, |
| "SELECT EXISTS(SELECT 1 FROM messages), EXISTS(SELECT 1 FROM threads)").Scan(&hasMsg, &hasThread); err != nil { |
| - return 0, false, false, err |
| + return 0, 0, false, false, err |
| } |
| - return members, v == "1", hasMsg == 1 || hasThread == 1, nil |
| + return members, people, v == "1", hasMsg == 1 || hasThread == 1, nil |
| } |
| // ---- trash purge (controld-side, root) ---------------------------------------- |
| @@ -155,7 +165,11 @@ func runTrashPurge(keep time.Duration) { |
| // instanceStats is what the control daemon reports about one space: the |
| // reaper's guards plus the storage meter's byte count (meter.go). |
| type instanceStats struct { |
| - Members int `json:"members"` |
| + Members int `json:"members"` |
| + // People is the non-agent member count — what per-seat billing charges |
| + // for (bots are free). Absent from pre-people controld responses, where |
| + // it decodes as 0; consumers fall back to Members. |
| + People int `json:"people"` |
| SetupComplete bool `json:"setup_complete"` |
| HasContent bool `json:"has_content"` |
| StorageBytes int64 `json:"storage_bytes"` |
diff --git a/reaper_test.go b/reaper_test.go| index ca187aa..e3a6243 100644 |
| --- a/reaper_test.go |
| +++ b/reaper_test.go |
| @@ -319,16 +319,16 @@ func TestInstanceStatusAndStatsHandler(t *testing.T) { |
| t.Fatal(err) |
| } |
| - if m, sc, hc, err := instanceStatus("used.11ch.at"); err != nil || m != 1 || !sc || hc { |
| + if m, _, sc, hc, err := instanceStatus("used.11ch.at"); err != nil || m != 1 || !sc || hc { |
| t.Errorf("instanceStatus(used) = (%d, %v, %v, %v); want (1, true, false, nil)", m, sc, hc, err) |
| } |
| - if m, sc, hc, err := instanceStatus("fresh.11ch.at"); err != nil || m != 1 || sc || hc { |
| + if m, _, sc, hc, err := instanceStatus("fresh.11ch.at"); err != nil || m != 1 || sc || hc { |
| t.Errorf("instanceStatus(fresh) = (%d, %v, %v, %v); want (1, false, false, nil)", m, sc, hc, err) |
| } |
| - if m, sc, hc, err := instanceStatus("hascontent.11ch.at"); err != nil || m != 1 || sc || !hc { |
| + if m, _, sc, hc, err := instanceStatus("hascontent.11ch.at"); err != nil || m != 1 || sc || !hc { |
| t.Errorf("instanceStatus(hascontent) = (%d, %v, %v, %v); want (1, false, true, nil)", m, sc, hc, err) |
| } |
| - if _, _, _, err := instanceStatus("missing.11ch.at"); !os.IsNotExist(err) { |
| + if _, _, _, _, err := instanceStatus("missing.11ch.at"); !os.IsNotExist(err) { |
| t.Errorf("missing instance should be not-exist, got %v", err) |
| } |
diff --git a/web/status/economics.html b/web/status/economics.html| index 1a73505..e0f8306 100644 |
| --- a/web/status/economics.html |
| +++ b/web/status/economics.html |
| @@ -171,9 +171,14 @@ break_even_GB = net ÷ (storage_rate × 12) # where a space stops coveri |
| charges,</span> which is why the picker defaults to annual (and why a |
| space is priced as a years-scale commitment).</li> |
| <li><span class="lead-in">The fixed floor is small.</span> A modest number |
| - of Family spaces, a dozen Community & Friends annuals, or a single |
| - Business space covers the whole serving plane; beyond that, margin is |
| - contribution toward resilience.</li> |
| + of Family spaces, a dozen Community & Friends annuals, or one |
| + mid-sized Business team covers the whole serving plane; beyond that, |
| + margin is contribution toward resilience.</li> |
| + <li><span class="lead-in">Business is per-seat, and seats settle at |
| + renewal.</span> A seat is a person (helpers and bots are free), counted |
| + server-side. The price adjusts to the team’s size before each annual |
| + renewal — shrinkage renews cheaper, growth renews dearer, and nothing is |
| + charged or credited mid-year.</li> |
| <li><span class="lead-in">Business margin is deliberately not an infra |
| story.</span> At that margin the price is buying priority support and |
| features to come, not bytes. If that feels wrong when read beside the |
| @@ -212,7 +217,9 @@ break_even_GB = net ÷ (storage_rate × 12) # where a space stops coveri |
| const FIELDS = [ |
| "as_of", "currency", "free_spaces_per_account", |
| "price_book[].tier", "price_book[].monthly_cents", "price_book[].yearly_cents", |
| + "price_book[].per_seat", |
| "storage_allowances[].tier", "storage_allowances[].gb", |
| + "storage_allowances[].per_seat", |
| "input_rates[].input", "input_rates[].rate", "input_rates[].source", |
| "estimates[].plan", "estimates[].billing", "estimates[].profile", |
| "estimates[].gross_year_cents", "estimates[].net_year_cents", |
| @@ -247,11 +254,14 @@ function render(d) { |
| for (const p of d.price_book || []) { |
| const tr = document.createElement("tr"); |
| const hasMonthly = typeof p.monthly_cents === "number"; |
| - const price = (hasMonthly ? eur.format(p.monthly_cents / 100) + "/month or " : "") |
| - + eur.format(p.yearly_cents / 100) + "/year"; |
| + const price = p.per_seat |
| + ? eur.format(p.yearly_cents / 100) + " per person per year" |
| + : (hasMonthly ? eur.format(p.monthly_cents / 100) + "/month or " : "") |
| + + eur.format(p.yearly_cents / 100) + "/year"; |
| tr.append(cell("th", null, p.tier), |
| cell("td", "num", price), |
| - cell("td", null, hasMonthly ? "monthly or annual" : "annual only")); |
| + cell("td", null, p.per_seat ? "annual only, per seat" |
| + : hasMonthly ? "monthly or annual" : "annual only")); |
| prices.append(tr); |
| } |
| @@ -259,7 +269,8 @@ function render(d) { |
| storage.replaceChildren(); |
| for (const s of d.storage_allowances || []) { |
| const tr = document.createElement("tr"); |
| - tr.append(cell("th", null, s.tier), cell("td", "num", gbFmt(s.gb))); |
| + tr.append(cell("th", null, s.tier), |
| + cell("td", "num", gbFmt(s.gb) + (s.per_seat ? " per person (pooled)" : ""))); |
| storage.append(tr); |
| } |
| const q = d.free_spaces_per_account; |
diff --git a/web/status/economics.json b/web/status/economics.json| index 32ff53a..a59dfe5 100644 |
| --- a/web/status/economics.json |
| +++ b/web/status/economics.json |
| @@ -1,30 +1,124 @@ |
| { |
| - "as_of": "2026-07-14", |
| - "currency": "eur", |
| - "free_spaces_per_account": 3, |
| - "price_book": [ |
| - { "tier": "Family", "yearly_cents": 500 }, |
| - { "tier": "Community & Friends", "monthly_cents": 400, "yearly_cents": 4000 }, |
| - { "tier": "Business", "monthly_cents": 9900, "yearly_cents": 99900 } |
| - ], |
| - "storage_allowances": [ |
| - { "tier": "Free", "gb": 1 }, |
| - { "tier": "Family", "gb": 5 }, |
| - { "tier": "Community & Friends", "gb": 20 }, |
| - { "tier": "Business", "gb": 1000 } |
| - ], |
| - "input_rates": [ |
| - { "input": "Stripe fee (EU consumer card)", "rate": "1.5% + €0.25 per charge", "source": "Stripe pricing; EUR settlement, no FX" }, |
| - { "input": "Attachment storage (R2)", "rate": "~€0.014 per GB-month", "source": "$0.015, zero egress" }, |
| - { "input": "DB snapshots (S3)", "rate": "~€0.021 per GB-month", "source": "$0.023" }, |
| - { "input": "Box (whole cloud plane)", "rate": "~€35 per month", "source": "Hetzner + fixed services" }, |
| - { "input": "Resident-process slot", "rate": "~€0.81 per month", "source": "the box rate divided across its full instance capacity" } |
| - ], |
| - "estimates": [ |
| - { "plan": "Family", "billing": "yearly", "gross_year_cents": 500, "net_year_cents": 468, "profile": "10 members, 5 GB, mostly asleep", "cost_year_cents": 100, "profit_year_cents": 370, "margin_pct": 74, "break_even_gb": 28 }, |
| - { "plan": "Community & Friends", "billing": "yearly", "gross_year_cents": 4000, "net_year_cents": 3915, "profile": "40 members, 20 GB, awake ~20%", "cost_year_cents": 550, "profit_year_cents": 3370, "margin_pct": 84, "break_even_gb": 230 }, |
| - { "plan": "Community & Friends", "billing": "monthly", "gross_year_cents": 4800, "net_year_cents": 4428, "profile": "40 members, 20 GB, awake ~20%", "cost_year_cents": 550, "profit_year_cents": 3880, "margin_pct": 81, "break_even_gb": 265 }, |
| - { "plan": "Business", "billing": "yearly", "gross_year_cents": 99900, "net_year_cents": 98377, "profile": "150 members, 100 GB, awake ~50%", "cost_year_cents": 2200, "profit_year_cents": 96200, "margin_pct": 96, "break_even_gb": 5900 }, |
| - { "plan": "Business", "billing": "monthly", "gross_year_cents": 118800, "net_year_cents": 116718, "profile": "150 members, 100 GB, awake ~50%", "cost_year_cents": 2200, "profit_year_cents": 114500, "margin_pct": 96, "break_even_gb": 7000 } |
| - ] |
| + "as_of": "2026-07-22", |
| + "currency": "eur", |
| + "free_spaces_per_account": 3, |
| + "price_book": [ |
| + { |
| + "tier": "Family", |
| + "yearly_cents": 500 |
| + }, |
| + { |
| + "tier": "Community & Friends", |
| + "monthly_cents": 400, |
| + "yearly_cents": 4000 |
| + }, |
| + { |
| + "tier": "Business", |
| + "yearly_cents": 1100, |
| + "per_seat": true |
| + } |
| + ], |
| + "storage_allowances": [ |
| + { |
| + "tier": "Free", |
| + "gb": 1 |
| + }, |
| + { |
| + "tier": "Family", |
| + "gb": 5 |
| + }, |
| + { |
| + "tier": "Community & Friends", |
| + "gb": 20 |
| + }, |
| + { |
| + "tier": "Business", |
| + "gb": 11, |
| + "per_seat": true |
| + } |
| + ], |
| + "input_rates": [ |
| + { |
| + "input": "Stripe fee (EU consumer card)", |
| + "rate": "1.5% + €0.25 per charge", |
| + "source": "Stripe pricing; EUR settlement, no FX" |
| + }, |
| + { |
| + "input": "Attachment storage (R2)", |
| + "rate": "~€0.014 per GB-month", |
| + "source": "$0.015, zero egress" |
| + }, |
| + { |
| + "input": "DB snapshots (S3)", |
| + "rate": "~€0.021 per GB-month", |
| + "source": "$0.023" |
| + }, |
| + { |
| + "input": "Box (whole cloud plane)", |
| + "rate": "~€35 per month", |
| + "source": "Hetzner + fixed services" |
| + }, |
| + { |
| + "input": "Resident-process slot", |
| + "rate": "~€0.81 per month", |
| + "source": "the box rate divided across its full instance capacity" |
| + } |
| + ], |
| + "estimates": [ |
| + { |
| + "plan": "Family", |
| + "billing": "yearly", |
| + "gross_year_cents": 500, |
| + "net_year_cents": 468, |
| + "profile": "10 members, 5 GB, mostly asleep", |
| + "cost_year_cents": 100, |
| + "profit_year_cents": 370, |
| + "margin_pct": 74, |
| + "break_even_gb": 28 |
| + }, |
| + { |
| + "plan": "Community & Friends", |
| + "billing": "yearly", |
| + "gross_year_cents": 4000, |
| + "net_year_cents": 3915, |
| + "profile": "40 members, 20 GB, awake ~20%", |
| + "cost_year_cents": 550, |
| + "profit_year_cents": 3370, |
| + "margin_pct": 84, |
| + "break_even_gb": 230 |
| + }, |
| + { |
| + "plan": "Community & Friends", |
| + "billing": "monthly", |
| + "gross_year_cents": 4800, |
| + "net_year_cents": 4428, |
| + "profile": "40 members, 20 GB, awake ~20%", |
| + "cost_year_cents": 550, |
| + "profit_year_cents": 3880, |
| + "margin_pct": 81, |
| + "break_even_gb": 265 |
| + }, |
| + { |
| + "plan": "Business", |
| + "billing": "yearly", |
| + "gross_year_cents": 33000, |
| + "net_year_cents": 32480, |
| + "profile": "30 people, 60 GB pooled, awake ~50%", |
| + "cost_year_cents": 2000, |
| + "profit_year_cents": 30480, |
| + "margin_pct": 92, |
| + "break_even_gb": 1900 |
| + }, |
| + { |
| + "plan": "Business", |
| + "billing": "yearly", |
| + "gross_year_cents": 165000, |
| + "net_year_cents": 162500, |
| + "profile": "150 people, 300 GB pooled, awake ~50%", |
| + "cost_year_cents": 7500, |
| + "profit_year_cents": 155000, |
| + "margin_pct": 94, |
| + "break_even_gb": 9700 |
| + } |
| + ] |
| } |