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>
9 files changed,
+310
−171
billing.go+13 −3cloud_perseat_test.go+73 −0docs/pricing-bands.md+30 −17docs/tier-economics.md+20 −19meter.go+1 −1test/stripe-band-prices.test.mjs+31 −26tools/stripe-band-prices.mjs+107 −76web/status/economics.html+10 −5web/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", |