eleven / messenger Public

server+docs+status: the elevens ladder — Community per-member with a min-seats floor, band tool rewrite, docs/status sweep (#1359)

- billing.go: MinSeats on the price book — quantity = max(people, min_seats);
  €0.11 × 100 IS the €11 minimum with the first 100 members included, so the
  floor needs no separate Stripe Price and bands scale it for free. Renewal
  true-up floors there too. publicTiers emits min_seats.
- tools/stripe-band-prices.mjs: rewritten to mint the whole ladder (base +
  bands, lookup keys eleven11-*, unit_amount_decimal for sub-cent per-member
  rates), reusing existing products; old eleven-* prices untouched.
- docs/pricing-bands.md + docs/tier-economics.md: the new matrix and tables.
- web/status/economics.{json,html}: Family 550c, Community per-member row
  (min 100), C&F allowance 11 GB, monthly rows retired.

No changelog here — the member-facing price announcement rides the env flip,
not this binary.


Claude-Session: https://claude.ai/code/session_01Wdvi8SBfSxzKpUYcdwXnjM
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3ebfcc1dcaf239ea11b0016c67d79a609d870278 parent 0b833c1
9 files changed, +310 −171
  • billing.go +13 −3
  • cloud_perseat_test.go +73 −0
  • docs/pricing-bands.md +30 −17
  • docs/tier-economics.md +20 −19
  • meter.go +1 −1
  • test/stripe-band-prices.test.mjs +31 −26
  • tools/stripe-band-prices.mjs +107 −76
  • web/status/economics.html +10 −5
  • web/status/economics.json +25 −24
diff --git a/billing.go b/billing.go
index 974051e..c2c12bd 100644
--- a/billing.go
+++ b/billing.go
@@ -70,6 +70,11 @@ type billingTier struct {
// 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"`
+ // MinSeats floors a per-seat tier's billed quantity — the included-members
+ // identity (docs/tier-economics.md): Community's €0.11/member with
+ // MinSeats 100 IS the €11 minimum covering the first 100 members, so the
+ // floor needs no separate Stripe Price and bands scale it for free.
+ MinSeats int `json:"min_seats,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
@@ -172,6 +177,9 @@ func (b *stripeBilling) publicTiers() []map[string]any {
if t.PerSeat {
entry["per_seat"] = true
}
+ if t.MinSeats > 0 {
+ entry["min_seats"] = t.MinSeats
+ }
if len(t.Bands) > 0 {
bands := map[string]any{}
for band, bd := range t.Bands {
@@ -316,7 +324,7 @@ func (s *cloudServer) checkoutHandler(w http.ResponseWriter, r *http.Request) {
writeControlErr(w, http.StatusBadGateway, "checkout_failed", "couldn't count the space's members — please try again")
return
}
- seats = max(1, st.People)
+ seats = max(st.People, tier.MinSeats, 1)
}
_, checkoutURL, err := s.billing.createCheckoutSession(id, tier, band, price, seats, req.SuccessURL, req.CancelURL, false)
if err != nil {
@@ -433,8 +441,10 @@ func (s *cloudServer) checkoutCreateHandler(w http.ResponseWriter, r *http.Reque
return
}
// 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)
+ // tiers open at one seat (or the tier's included-members floor — a paid
+ // community create charges its €11 minimum up front); growth settles at
+ // the renewal true-up.
+ session, checkoutURL, err := s.billing.createCheckoutSession(id, tier, band, price, max(tier.MinSeats, 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)
diff --git a/cloud_perseat_test.go b/cloud_perseat_test.go
index 399adc0..9bcf828 100644
--- a/cloud_perseat_test.go
+++ b/cloud_perseat_test.go
@@ -24,6 +24,8 @@ func perSeatTiers() []billingTier {
{Key: "friends", Name: "Friends", Label: "€4/month", Price: "price_friends_m", NetEURYear: 44},
{Key: "business", Name: "Business", Label: "€11 per person per year", Price: "price_business_seat_y",
NetEURYear: 10.8, EUR: 11, Business: true, PerSeat: true},
+ {Key: "community", Name: "Community & Friends", Label: "€0.11 per member per year", Price: "price_community_member_y",
+ NetEURYear: 0.108, EUR: 0.11, PerSeat: true, MinSeats: 100},
}
}
@@ -371,3 +373,74 @@ INSERT INTO users(id, created_at) VALUES('admin',1),('pal',1);`); err != nil {
t.Errorf("legacy instanceStatus = (members %d, people %d, %v); want (2, 2, nil)", m, p, err)
}
}
+
+func TestCheckoutMinSeatsFloor(t *testing.T) {
+ stripe, captured := fakeStripe(t)
+ srv, s := newTestCloud(t, statsControl(t, 10, 7, false))
+ s.billing = &stripeBilling{
+ apiBase: stripe.URL, key: "sk_test", webhookSecret: "whsec_test",
+ tiers: perSeatTiers(), client: http.DefaultClient,
+ }
+ tok := mintCaller(t, srv, "home")
+ host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string)
+
+ // 7 people on a min-100 tier bill the floor — €0.11 × 100 IS the €11
+ // minimum with the first 100 members included, no separate Price.
+ resp := postCheckout(t, srv, tok, host, "user-1", "community")
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ if q := (*captured).Get("line_items[0][quantity]"); q != "100" {
+ t.Errorf("quantity = %q, want the 100-seat floor over 7 people", q)
+ }
+}
+
+func TestCheckoutMinSeatsAboveFloor(t *testing.T) {
+ stripe, captured := fakeStripe(t)
+ srv, s := newTestCloud(t, statsControl(t, 150, 140, false))
+ s.billing = &stripeBilling{
+ apiBase: stripe.URL, key: "sk_test", webhookSecret: "whsec_test",
+ tiers: perSeatTiers(), client: http.DefaultClient,
+ }
+ tok := mintCaller(t, srv, "home")
+ host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string)
+
+ resp := postCheckout(t, srv, tok, host, "user-1", "community")
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ if q := (*captured).Get("line_items[0][quantity]"); q != "140" {
+ t.Errorf("quantity = %q, want 140 (the live count once past the floor)", q)
+ }
+}
+
+func TestSeatTrueUpFloorsAtMinSeats(t *testing.T) {
+ db := openTestCloudDB(t)
+ id := insertSpaceRow(t, db, "club.11ch.at", 1000)
+ renewAt := time.Now().Add(3 * 24 * time.Hour).Unix()
+ if _, err := db.sql.Exec(
+ `UPDATE spaces SET entitlement='paid', tier='community', seats=5, paid_until=?, stripe_subscription_id='sub_1' WHERE id=?`,
+ renewAt, id); err != nil {
+ t.Fatal(err)
+ }
+
+ updated := url.Values{}
+ stripe := seatStripe(t, 5, &updated)
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]any{"members": 10, "people": 7, "setup_complete": true, "storage_bytes": 1})
+ })
+ mux.HandleFunc("POST /instances/{host}/quota", func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+ })
+ s := &cloudServer{
+ db: db, control: controlClient(fakeControl(t, mux.ServeHTTP)), controlToken: "t",
+ billing: &stripeBilling{apiBase: stripe.URL, key: "sk_test", tiers: perSeatTiers(), client: http.DefaultClient},
+ }
+
+ s.meterOnce()
+ // Renewal never trues up below the floor: 7 people still renew at 100.
+ if updated.Get("items[0][quantity]") != "100" {
+ t.Errorf("update form = %v, want quantity trued up to the 100-seat floor", updated)
+ }
+}
diff --git a/docs/pricing-bands.md b/docs/pricing-bands.md
index eded3f8..04eeb49 100644
--- a/docs/pricing-bands.md
+++ b/docs/pricing-bands.md
@@ -25,11 +25,14 @@ Multipliers on the existing EUR base prices, charm-rounded:
| Tier | Supporter ×1.4 | Base ×1.0 | Reduced ×0.5 | Solidarity ×0.25 |
|---|---|---|---|---|
-| 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 |
+| Family (annual) | €7.70/y | €5.50/y | €2.75/y | €1.40/y |
+| Community (annual, per member) | €0.15/member/y | €0.11/member/y | €0.055/member/y | €0.0275/member/y |
| Business (annual, per seat) | €15/person/y | €11/person/y | €5.50/person/y | — |
+Community's €11 minimum needs no row (and no Stripe Price) of its own: the
+billed quantity floors at 100 members, so each band's minimum is just 100 ×
+its member rate — supporter €15, base €11, reduced €5.50, solidarity €2.75.
+
- **Family and C&F prices are VAT-inclusive; Business prices are
VAT-exclusive** (decided 2026-07-14) — the standard B2C/B2B split: a
consumer's €5 means €5 with VAT inside; Business is quoted ex-VAT and tax
@@ -42,6 +45,11 @@ Multipliers on the existing EUR base prices, charm-rounded:
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.
+- **C&F went per-member 2026-07-27** (€0.11/member/year, €11 minimum with the
+ first 100 members included — the elevens ladder): monthly billing is gone
+ ladder-wide (the fixed fee would eat it), and the per-member bands ride
+ Stripe's `unit_amount_decimal` — fractional cents are exactly what a
+ €0.055/member Reduced rate needs.
- **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
@@ -222,8 +230,8 @@ Phase 2 ships.
## Stripe implementation
-**19 Prices** (nominally 5 today × 4 bands, minus C&F monthly's solidarity —
-annual-only by the fee-floor rule above), minted by the committed tool
+**11 Prices** (nominally 3 tiers × 4 bands, minus Business's solidarity —
+the elevens ladder, annual-only throughout), minted by the committed tool
`tools/stripe-band-prices.mjs` — its `BAND_TABLE` is this doc's matrix as
code, reviewed like any diff. The operator runs it from their own terminal
(sandbox key first, then live; the key never transits a session):
@@ -233,22 +241,27 @@ STRIPE_SECRET_KEY=sk_… node tools/stripe-band-prices.mjs --book current.json
STRIPE_SECRET_KEY=sk_… node tools/stripe-band-prices.mjs --book current.json --apply
```
-It verifies each base Price against the table (aborts on mismatch — wrong
-account mints nothing), backfills `tax_behavior` where still unspecified,
-creates the missing band Prices idempotently (lookup keys
-`eleven-<tier>-<band>`; re-runs create nothing), and prints the complete new
-price book — sealed into `/data/cloud.env` via the vault flow. The book grows
-one backward-compatible dimension (a tier with no `bands` behaves exactly as
-today):
+It reuses each tier's existing Stripe product where the current book has one
+(subscriber history stays put), creates every missing ladder Price — base and
+bands — idempotently (lookup keys `eleven11-<tier>-<band>`; re-runs create
+nothing; the old `eleven-*` prices stay untouched for lapsing subscribers),
+and prints the complete new price book — sealed into `/data/cloud.env` via
+the operator env flow. Per-unit tiers ride `unit_amount_decimal`, and the
+book carries the flags the cloud already understands (`per_seat`,
+`min_seats`, `business`):
```json
[{"key":"family","name":"Family",
- "label":"€5/year","price":"price_baseFam",
- "net_eur_year":4.68,
+ "label":"€5.50/year","price":"price_baseFam",
+ "eur":5.5,"net_eur_year":5.168,
"bands":{
- "supporter":{"label":"€7/year","price":"price_supFam","net_eur_year":6.55},
- "reduced":{"label":"€2.50/year","price":"price_redFam","net_eur_year":2.31},
- "solidarity":{"label":"€1.25/year","price":"price_solFam","net_eur_year":0.98}}}]
+ "supporter":{"label":"€7.70/year","price":"price_supFam","eur":7.7,"net_eur_year":7.335},
+ "reduced":{"label":"€2.75/year","price":"price_redFam","eur":2.75,"net_eur_year":2.459},
+ "solidarity":{"label":"€1.40/year","price":"price_solFam","eur":1.4,"net_eur_year":1.129}}},
+ {"key":"community","name":"Community & Friends",
+ "label":"€0.11/member/year","price":"price_baseCom",
+ "eur":0.11,"net_eur_year":0.108,"per_seat":true,"min_seats":100,
+ "bands":{"…":"…"}}]
```
(`net_eur_year` is the free pool's revenue input — see `docs/free-pool.md`.)
diff --git a/docs/tier-economics.md b/docs/tier-economics.md
index 8116096..9fb37f7 100644
--- a/docs/tier-economics.md
+++ b/docs/tier-economics.md
@@ -33,8 +33,8 @@ Living doc: the *formulas* are the contract; the *numbers* are a snapshot
| Tier | Price | Billing |
|---|---|---|
-| Family | €5/year | annual only |
-| Community & Friends | €4/month or €40/year | toggle |
+| Family | €5.50/year | annual only |
+| Community & Friends | €0.11 per member per year, €11 minimum (first 100 members included) | annual only, per member |
| Business | €11 per person per year | annual only, per seat |
What paying buys today: the space stops counting toward the account's
@@ -91,7 +91,7 @@ allowance_GB = (net − 0.80 × gross − compute_cost) ÷ (storage_rate × 12)
```
— 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
+has no revenue (its 1 GB is pure cost containment), and Family at €5.50/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.
@@ -199,34 +199,35 @@ 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-22)
+## Estimates per tier (snapshot, 2026-07-27)
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 |
+| Family €5.50/y | €5.50 | €5.17 | 10 members, 5 GB, mostly asleep | ~€1.00 | ~€4.15 | ~76% | ~31 GB |
+| Community €11 min | €11.00 | €10.59 | 40 members (first 100 included), 11 GB, awake ~20% | ~€5.50 | ~€5.10 | ~46% | ~40 GB |
+| Community €0.11/member | €55.00 | €53.93 | 500 members, 11 GB, awake ~50% | ~€12.00 | ~€42 | ~76% | ~320 GB |
| 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:
- **Break-even storage is the number that matters.** A Family space must hold
- ~28 GB of media *forever* before €5/year stops covering it. That makes
- #561's storage quotas easy to set generously: ~20 GB Family, ~100–200 GB
- C&F, ~1–2 TB Business keeps every tier structurally unable to lose money
- while feeling unlimited in practice.
-- **Stripe's fixed fee punishes small monthly charges.** Family pays 6.5% of
- gross in fees on its single annual charge; a hypothetical €0.42/month
- Family would pay ~11×. C&F monthly hands Stripe €3.72/year vs €0.85 on
- annual. The picker defaults to Annual for this reason (and because a space
- is a years-scale commitment).
-- **The fixed floor is small.** The whole plane costs ~€420/year; roughly 85
- Family spaces, or a dozen C&F annuals, or one Business space cover it
- outright. Beyond that, margin is contribution toward resilience and the
+ ~31 GB of media *forever* before €5.50/year stops covering it. That makes
+ #561's storage quotas easy to set generously: the shipped allowances
+ (5 GB Family, 11 GB Community, 11 GB per seat Business) sit far below
+ every tier's break-even, so no tier can structurally lose money while
+ still feeling roomy in practice.
+- **Stripe's fixed fee punishes small monthly charges.** Family pays ~6% of
+ gross in fees on its single annual charge; a hypothetical €0.46/month
+ Family would pay ~11×. This is why the whole ladder is annual only — at
+ €0.11 a member, a monthly charge would be mostly Stripe fee (and a space
+ is a years-scale commitment anyway).
+- **The fixed floor is small.** The whole plane costs ~€420/year; roughly 80
+ Family spaces, or ~40 Community minimums, or one 40-person 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 ~92–94% margin
on realistic teams the price is buying priority support, SLAs, and features
diff --git a/meter.go b/meter.go
index 1658fd7..3fd127e 100644
--- a/meter.go
+++ b/meter.go
@@ -368,7 +368,7 @@ func (s *cloudServer) reconcileSeats(sp meterRow, people int, now int64) {
if sp.PaidUntil <= now || sp.PaidUntil-now > int64(seatTrueUpWindow/time.Second) {
return
}
- want := max(people, 1)
+ want := max(people, t.MinSeats, 1)
if want == sp.Seats {
return
}
diff --git a/test/stripe-band-prices.test.mjs b/test/stripe-band-prices.test.mjs
index 6da5ac9..abc260e 100644
--- a/test/stripe-band-prices.test.mjs
+++ b/test/stripe-band-prices.test.mjs
@@ -14,43 +14,48 @@ async function test(name, fn) {
}
}
-await test("the table is the doc's charm-rounded matrix — 19 prices in all", () => {
- // 5 base + 14 band prices: nominally 5 × 4, minus friends monthly's
- // solidarity — deliberately absent (Stripe's fixed fee makes ~€1/month
- // fee-hostile; annual matches how those buyers think about €10).
+await test("the table is the elevens ladder — 11 prices in all", () => {
+ // 3 base + 8 band prices: nominally 3 × 4, minus Business's solidarity
+ // (deliberately absent — a hardship price aimed at businesses never
+ // parsed; Reduced at €5.50/person covers nonprofits/education).
const tiers = Object.keys(BAND_TABLE);
- assert.equal(tiers.length, 5);
+ assert.deepEqual(tiers, ["family", "community", "business"]);
const bandCount = tiers.reduce((n, t) => n + Object.keys(BAND_TABLE[t].bands).length, 0);
- assert.equal(tiers.length + bandCount, 19);
- assert.ok(!BAND_TABLE.friends.bands.solidarity, "friends monthly must not offer solidarity");
- // Spot-pin the doc's boldest figures.
- assert.equal(BAND_TABLE.family.bands.supporter, 700);
- assert.equal(BAND_TABLE.family.bands.solidarity, 125);
- assert.equal(BAND_TABLE["business-year"].bands.supporter, 139900);
- assert.equal(BAND_TABLE["business-year"].bands.reduced, 49900);
- // Bands only ever use the fixed vocabulary.
+ assert.equal(tiers.length + bandCount, 11);
+ assert.ok(!BAND_TABLE.business.bands.solidarity, "business must not offer solidarity");
+ // Spot-pin the ladder's load-bearing figures (decimal cents).
+ assert.equal(BAND_TABLE.family.base, "550");
+ assert.equal(BAND_TABLE.community.base, "11");
+ assert.equal(BAND_TABLE.business.base, "1100");
+ assert.equal(BAND_TABLE.business.bands.supporter, "1500");
+ assert.equal(BAND_TABLE.business.bands.reduced, "550");
+ // The included-members identity: €0.11 × the 100-member floor = the €11
+ // minimum, and every band scales it (supporter €15, reduced €5.50).
+ assert.equal(BAND_TABLE.community.minSeats, 100);
+ assert.equal(Number(BAND_TABLE.community.base) * BAND_TABLE.community.minSeats, 1100);
+ // Bands only ever use the fixed vocabulary; per-unit tiers name their unit.
for (const t of tiers) {
for (const b of Object.keys(BAND_TABLE[t].bands)) {
assert.ok(["supporter", "reduced", "solidarity"].includes(b), `${t}: unknown band ${b}`);
}
- assert.ok(["month", "year"].includes(BAND_TABLE[t].interval));
+ assert.ok([null, "member", "seat"].includes(BAND_TABLE[t].unit));
}
});
-await test("priceLabel: whole euros bare, cents as a pair, thousands delimited", () => {
- assert.equal(priceLabel(500, "year"), "€5/year");
- assert.equal(priceLabel(550, "month"), "€5.50/month");
- assert.equal(priceLabel(250, "year"), "€2.50/year");
- assert.equal(priceLabel(139900, "year"), "€1,399/year");
+await test("priceLabel: whole euros bare, cents as a pair, sub-cent to three places", () => {
+ assert.equal(priceLabel("550", null), "€5.50/year");
+ assert.equal(priceLabel("1100", "seat"), "€11/seat/year");
+ assert.equal(priceLabel("11", "member"), "€0.11/member/year");
+ assert.equal(priceLabel("5.5", "member"), "€0.055/member/year");
+ assert.equal(priceLabel("139900", null), "€1,399/year");
});
-await test("netEURYear: the EEA fee model, per charge", () => {
- // Annual: one charge — €5 → 5 − 0.075 − 0.25 = 4.68 (the doc's own figure).
- assert.equal(netEURYear(500, "year"), 4.68);
- // Monthly: twelve charges — €4/m → 48 − 0.72 − 3.00 = 44.28.
- assert.equal(netEURYear(400, "month"), 44.28);
- // The fixed fee visibly bites the small annual prices (why solidarity is annual-only).
- assert.equal(netEURYear(125, "year"), 0.98);
+await test("netEURYear: the EEA fee model — flat absorbs the fixed fee, per-unit doesn't", () => {
+ // Flat annual: one charge — €5.50 → 5.50 − 0.0825 − 0.25 = 5.168.
+ assert.equal(netEURYear("550", null), 5.168);
+ // Per-unit rates only carry the percentage; the €0.25 lands once per space.
+ assert.equal(netEURYear("1100", "seat"), 10.835);
+ assert.equal(netEURYear("11", "member"), 0.108);
});
console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
diff --git a/tools/stripe-band-prices.mjs b/tools/stripe-band-prices.mjs
index 068dd8d..c377fcd 100644
--- a/tools/stripe-band-prices.mjs
+++ b/tools/stripe-band-prices.mjs
@@ -1,61 +1,85 @@
#!/usr/bin/env node
-// 🤖 Mint the pricing-band Stripe Prices (docs/pricing-bands.md § Stripe
+// 🤖 Mint the elevens-ladder Stripe Prices (docs/pricing-bands.md § Stripe
// implementation) and emit the new LCHAT_STRIPE_PRICES book.
//
// The band table below IS the committed, reviewable artifact — the doc's
-// charm-rounded matrix, in cents. The operator runs this from their own
-// terminal (the secret key never transits a session or the repo):
+// charm-rounded matrix, in decimal cents. The operator runs this from their
+// own terminal (the secret key never transits a session or the repo):
//
// STRIPE_SECRET_KEY=sk_test_… node tools/stripe-band-prices.mjs --book current.json
// STRIPE_SECRET_KEY=sk_test_… node tools/stripe-band-prices.mjs --book current.json --apply
//
-// where current.json holds today's LCHAT_STRIPE_PRICES value. Dry-run by
-// default: it prints every action it WOULD take. With --apply it:
+// where current.json holds today's LCHAT_STRIPE_PRICES value — consulted only
+// to reuse each tier's existing Stripe product, so subscriber dashboards keep
+// their product history. Dry-run by default: it prints every action it WOULD
+// take. With --apply it:
//
-// 1. verifies each tier's base Price (currency, amount — a mismatch aborts:
-// wrong account or wrong key, nothing is minted),
-// 2. sets tax_behavior on base Prices still "unspecified" (consumer tiers
-// inclusive, business exclusive — Stripe allows exactly one such set),
-// 3. creates each missing band Price on the SAME product and interval,
-// under lookup_key eleven-<tier>-<band> — the idempotency handle: a
-// re-run finds them and creates nothing,
-// 4. prints the complete new price-book JSON, ready for the vault flow
-// (labels + eur + net_eur_year per band, business flags set).
+// 1. resolves each ladder tier's product from the current book (first key in
+// productFrom that's present), creating the product only for a tier the
+// account has never sold,
+// 2. creates every missing ladder Price — base and bands — under lookup_key
+// eleven11-<tier>-<band>, the idempotency handle: a re-run finds them and
+// creates nothing. The old eleven-* prices are left untouched (existing
+// subscriptions keep renewing on them until they lapse),
+// 3. prints the complete new price-book JSON, ready for the env flip
+// (labels + eur + net_eur_year per band; per_seat/min_seats/business
+// flags set).
//
-// Test first with a sandbox key; the live run is the same command with the
-// live key. Nothing here deletes or mutates amounts — existing Prices are
-// immutable in Stripe and this tool leans on that.
+// Per-unit tiers (community per member, business per seat) use
+// unit_amount_decimal — Stripe takes fractional cents, which is how the
+// reduced/solidarity bands of a €0.11 price exist at all. Test first with a
+// sandbox key; the live run is the same command with the live key. Nothing
+// here deletes or mutates amounts — existing Prices are immutable in Stripe
+// and this tool leans on that.
-// ---- the table (docs/pricing-bands.md § The bands, in cents) -----------------
+// ---- the table (docs/pricing-bands.md § The bands, in decimal cents) ---------
+//
+// The whole ladder is annual-only (the fixed ~€0.25/charge makes small
+// monthly prices fee-hostile). unit: null = flat per space; "member"/"seat" =
+// per-unit, billed as checkout quantity. minSeats is the included-members
+// identity: €0.11 × 100 IS the €11 minimum, so the floor needs no separate
+// Price and every band scales it for free.
export const BAND_TABLE = {
- family: { base: 500, interval: "year", business: false, bands: { supporter: 700, reduced: 250, solidarity: 125 } },
- friends: { base: 400, interval: "month", business: false, bands: { supporter: 550, reduced: 200 } }, // solidarity is annual-only
- "friends-year": { base: 4000, interval: "year", business: false, bands: { supporter: 5600, reduced: 2000, solidarity: 1000 } },
- business: { base: 9900, interval: "month", business: true, bands: { supporter: 13900, reduced: 4900, solidarity: 2500 } },
- "business-year": { base: 99900, interval: "year", business: true, bands: { supporter: 139900, reduced: 49900, solidarity: 24900 } },
+ family: {
+ name: "Family", productFrom: ["family"], business: false, unit: null,
+ base: "550", bands: { supporter: "770", reduced: "275", solidarity: "140" },
+ },
+ community: {
+ name: "Community & Friends", productFrom: ["friends-year", "friends", "community"],
+ business: false, unit: "member", minSeats: 100,
+ base: "11", bands: { supporter: "15", reduced: "5.5", solidarity: "2.75" },
+ },
+ business: {
+ name: "Business", productFrom: ["business-year", "business"],
+ business: true, unit: "seat",
+ base: "1100", bands: { supporter: "1500", reduced: "550" },
+ },
};
-// priceLabel renders a cents amount as the book's display label: "€5/year",
-// "€5.50/month", "€1,399/year" — whole euros bare, cents as a pair.
-export function priceLabel(cents, interval) {
- const euros = cents / 100;
- const whole = Number.isInteger(euros);
+// priceLabel renders a decimal-cent amount as the book's display label:
+// "€5.50/year", "€0.11/member/year", "€0.055/member/year" — whole euros bare,
+// cents as a pair, sub-cent per-unit rates to three places.
+export function priceLabel(cents, unit) {
+ const euros = Number(cents) / 100;
+ const digits = Number.isInteger(euros) ? 0 : euros * 100 === Math.floor(euros * 100) ? 2 : 3;
const amount = euros.toLocaleString("en-IE", {
- minimumFractionDigits: whole ? 0 : 2,
- maximumFractionDigits: whole ? 0 : 2,
+ minimumFractionDigits: digits,
+ maximumFractionDigits: digits,
});
- return "€" + amount + "/" + interval;
+ return "€" + amount + "/" + (unit ? unit + "/" : "") + "year";
}
-// netEURYear estimates what one subscription nets per year after Stripe fees —
-// the free pool's revenue input. The model is the standard EEA card rate
-// (1.5% + €0.25 per charge); monthly tiers pay the fixed fee twelve times.
-// An estimate, stated as such: docs/free-pool.md tolerates the imprecision.
-export function netEURYear(cents, interval) {
- const charges = interval === "month" ? 12 : 1;
- const grossYear = (cents / 100) * charges;
- return Math.round((grossYear - grossYear * 0.015 - 0.25 * charges) * 100) / 100;
+// netEURYear estimates what a price nets per year after Stripe fees — the
+// free pool's revenue input, per space for flat tiers and per unit for
+// per-unit ones (the ~€0.25 fixed fee lands once per space's single annual
+// charge, so flat tiers absorb it whole and per-unit rates treat it as
+// space-level noise). An estimate, stated as such: docs/free-pool.md
+// tolerates the imprecision.
+export function netEURYear(cents, unit) {
+ const gross = Number(cents) / 100;
+ const net = unit ? gross - gross * 0.015 : gross - gross * 0.015 - 0.25;
+ return Math.round(net * 1000) / 1000;
}
// ---- Stripe plumbing ---------------------------------------------------------
@@ -92,47 +116,43 @@ async function main() {
const act = apply ? "" : "[dry-run] ";
const out = [];
- for (const tier of book) {
- const spec = BAND_TABLE[tier.key];
- if (!spec) {
- console.error(`! tier ${tier.key}: not in the band table — passing through unchanged`);
- out.push(tier);
- continue;
- }
- // 1. Verify the base Price is the one the table believes it is.
- const base = await stripe(key, "GET", "/v1/prices/" + tier.price);
- if (base.currency !== "eur" || base.unit_amount !== spec.base || base.recurring?.interval !== spec.interval) {
- throw new Error(`tier ${tier.key}: base price ${tier.price} is ${base.unit_amount} ${base.currency}/${base.recurring?.interval}, ` +
- `expected ${spec.base} eur/${spec.interval} — wrong account or stale book; nothing minted`);
- }
- // 2. Backfill tax_behavior while it's still settable (unspecified-only).
- const behavior = spec.business ? "exclusive" : "inclusive";
- if ((base.tax_behavior || "unspecified") === "unspecified") {
- console.error(`${act}set ${tier.price} tax_behavior=${behavior}`);
- if (apply) await stripe(key, "POST", "/v1/prices/" + tier.price, { tax_behavior: behavior });
- } else if (base.tax_behavior !== behavior) {
- console.error(`! tier ${tier.key}: base tax_behavior is ${base.tax_behavior}, wanted ${behavior} — immutable now; fix in the dashboard if it matters`);
+ for (const [tierKey, spec] of Object.entries(BAND_TABLE)) {
+ // 1. Reuse the tier's existing product so subscriber history stays on one
+ // product; a never-sold tier gets a fresh one.
+ const from = book.find((t) => spec.productFrom.includes(t.key));
+ let product;
+ if (from) {
+ const price = await stripe(key, "GET", "/v1/prices/" + from.price);
+ product = price.product;
+ console.error(`${act}${tierKey}: product ${product} (via book tier ${from.key})`);
+ } else if (apply) {
+ const created = await stripe(key, "POST", "/v1/products", { name: "Eleven " + spec.name });
+ product = created.id;
+ console.error(`${act}${tierKey}: created product ${product}`);
+ } else {
+ product = "prod_DRYRUN_" + tierKey;
+ console.error(`${act}${tierKey}: would create product "Eleven ${spec.name}"`);
}
- // 3. Each band: find by lookup_key or create on the same product.
- const bands = {};
- for (const [band, cents] of Object.entries(spec.bands)) {
- const lookup = `eleven-${tier.key}-${band}`;
+ // 2. Base + bands, each found-or-created under its versioned lookup key.
+ const behavior = spec.business ? "exclusive" : "inclusive";
+ const mint = async (band, cents) => {
+ const lookup = `eleven11-${tierKey}-${band}`;
const existing = await stripe(key, "GET", "/v1/prices?lookup_keys[]=" + encodeURIComponent(lookup) + "&limit=1");
let priceID = existing.data?.[0]?.id;
if (priceID) {
console.error(`${act}${lookup}: exists (${priceID})`);
} else {
- console.error(`${act}${lookup}: create ${cents}c eur/${spec.interval} tax_behavior=${behavior} on ${base.product}`);
+ console.error(`${act}${lookup}: create ${cents}c eur/year tax_behavior=${behavior} on ${product}`);
if (apply) {
const created = await stripe(key, "POST", "/v1/prices", {
- product: base.product,
+ product,
currency: "eur",
- unit_amount: String(cents),
- "recurring[interval]": spec.interval,
+ unit_amount_decimal: String(cents),
+ "recurring[interval]": "year",
tax_behavior: behavior,
lookup_key: lookup,
- "metadata[tier]": tier.key,
+ "metadata[tier]": tierKey,
"metadata[band]": band,
});
priceID = created.id;
@@ -140,22 +160,33 @@ async function main() {
priceID = "price_DRYRUN_" + lookup;
}
}
- bands[band] = {
- label: priceLabel(cents, spec.interval),
+ return {
+ label: priceLabel(cents, spec.unit),
price: priceID,
- eur: cents / 100,
- net_eur_year: netEURYear(cents, spec.interval),
+ eur: Number(cents) / 100,
+ net_eur_year: netEURYear(cents, spec.unit),
};
+ };
+
+ const base = await mint("base", spec.base);
+ const bands = {};
+ for (const [band, cents] of Object.entries(spec.bands)) {
+ bands[band] = await mint(band, cents);
}
- // 4. The tier's new book entry: today's fields kept, the band dimension added.
+ // 3. The tier's book entry, flags included.
const entry = {
- ...tier,
- eur: tier.eur || spec.base / 100,
- net_eur_year: tier.net_eur_year ?? netEURYear(spec.base, spec.interval),
+ key: tierKey,
+ name: spec.name,
+ label: base.label,
+ price: base.price,
+ eur: base.eur,
+ net_eur_year: base.net_eur_year,
bands,
};
if (spec.business) entry.business = true;
+ if (spec.unit) entry.per_seat = true;
+ if (spec.minSeats) entry.min_seats = spec.minSeats;
out.push(entry);
}
diff --git a/web/status/economics.html b/web/status/economics.html
index e0f8306..69a15ce 100644
--- a/web/status/economics.html
+++ b/web/status/economics.html
@@ -105,7 +105,7 @@
storage a space could fill <em>forever</em> with the tier still keeping at
least 80% of gross — 20% of the price buys your storage, 80% stays for
resilience. Two honest exceptions: Free has no revenue (its 1 GB is cost
- containment), and Family’s €5/year buys only ~3 GB at strict 80/20 — we
+ containment), and Family’s €5.50/year buys only ~3 GB at strict 80/20 — we
hold it at 5 GB rather than shrink the family tier. Uploads past an
allowance are refused; nothing is ever deleted, and messages always keep
working.</p>
@@ -216,8 +216,8 @@ break_even_GB = net ÷ (storage_rate × 12) # where a space stops coveri
// and every allowance < its tier's break-even).
const FIELDS = [
"as_of", "currency", "free_spaces_per_account",
- "price_book[].tier", "price_book[].monthly_cents", "price_book[].yearly_cents",
- "price_book[].per_seat",
+ "price_book[].tier", "price_book[].yearly_cents",
+ "price_book[].per_seat", "price_book[].per_member", "price_book[].min_members",
"storage_allowances[].tier", "storage_allowances[].gb",
"storage_allowances[].per_seat",
"input_rates[].input", "input_rates[].rate", "input_rates[].source",
@@ -254,13 +254,18 @@ function render(d) {
for (const p of d.price_book || []) {
const tr = document.createElement("tr");
const hasMonthly = typeof p.monthly_cents === "number";
- const price = p.per_seat
+ const price = p.per_member
+ ? eur.format(p.yearly_cents / 100) + " per member per year ("
+ + eur.format((p.min_members * p.yearly_cents) / 100) + " minimum — first "
+ + n1.format(p.min_members) + " members included)"
+ : 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, p.per_seat ? "annual only, per seat"
+ cell("td", null, p.per_member ? "annual only, per member"
+ : p.per_seat ? "annual only, per seat"
: hasMonthly ? "monthly or annual" : "annual only"));
prices.append(tr);
}
diff --git a/web/status/economics.json b/web/status/economics.json
index a59dfe5..b63e4da 100644
--- a/web/status/economics.json
+++ b/web/status/economics.json
@@ -1,16 +1,17 @@
{
- "as_of": "2026-07-22",
+ "as_of": "2026-07-27",
"currency": "eur",
"free_spaces_per_account": 3,
"price_book": [
{
"tier": "Family",
- "yearly_cents": 500
+ "yearly_cents": 550
},
{
"tier": "Community & Friends",
- "monthly_cents": 400,
- "yearly_cents": 4000
+ "yearly_cents": 11,
+ "per_member": true,
+ "min_members": 100
},
{
"tier": "Business",
@@ -29,7 +30,7 @@
},
{
"tier": "Community & Friends",
- "gb": 20
+ "gb": 11
},
{
"tier": "Business",
@@ -68,35 +69,35 @@
{
"plan": "Family",
"billing": "yearly",
- "gross_year_cents": 500,
- "net_year_cents": 468,
+ "gross_year_cents": 550,
+ "net_year_cents": 517,
"profile": "10 members, 5 GB, mostly asleep",
"cost_year_cents": 100,
- "profit_year_cents": 370,
- "margin_pct": 74,
- "break_even_gb": 28
+ "profit_year_cents": 417,
+ "margin_pct": 76,
+ "break_even_gb": 31
},
{
"plan": "Community & Friends",
"billing": "yearly",
- "gross_year_cents": 4000,
- "net_year_cents": 3915,
- "profile": "40 members, 20 GB, awake ~20%",
+ "gross_year_cents": 1100,
+ "net_year_cents": 1059,
+ "profile": "40 members (inside the included 100), 11 GB, awake ~20%",
"cost_year_cents": 550,
- "profit_year_cents": 3370,
- "margin_pct": 84,
- "break_even_gb": 230
+ "profit_year_cents": 509,
+ "margin_pct": 46,
+ "break_even_gb": 40
},
{
"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
+ "billing": "yearly",
+ "gross_year_cents": 5500,
+ "net_year_cents": 5393,
+ "profile": "500 members, 11 GB, awake ~50%",
+ "cost_year_cents": 1200,
+ "profit_year_cents": 4193,
+ "margin_pct": 76,
+ "break_even_gb": 320
},
{
"plan": "Business",