eleven / messenger Public

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>
Paul Campbell 0b833c1d07ac21e317b7ce88456b59d2eb510c06 parent b0ddb0f
20 files changed, +866 −132
  • billing.go +110 −9
  • changelog/2026-07-22-business-per-seat.md +6 −0
  • cloud.go +9 −4
  • cloud_bands_test.go +4 −4
  • cloud_billing_test.go +1 −1
  • cloud_db.go +30 −13
  • cloud_perseat_test.go +373 −0
  • controld.go +3 −3
  • demo.go +2 −2
  • demo_test.go +2 −2
  • docs/cloud-provisioner.md +3 −2
  • docs/pricing-bands.md +8 −3
  • docs/tier-economics.md +55 −16
  • meter.go +78 −17
  • meter_test.go +3 −3
  • pool.go +13 −6
  • reaper.go +22 −8
  • reaper_test.go +4 −4
  • web/status/economics.html +18 −7
  • web/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 &amp; 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 &amp; 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
+ }
+ ]
}