eleven / messenger Public

tools: stripe-band-prices — mint the honor-band Prices, emit the new book (Phase 0) (#1077)

The operational half of docs/pricing-bands.md Phase 0: a committed,
human-legible tool whose BAND_TABLE is the doc's charm-rounded matrix as
code. The operator runs it from their own terminal (sandbox key first, then
live — the key never transits a session):

  STRIPE_SECRET_KEY=sk_… node tools/stripe-band-prices.mjs --book current.json [--apply]

Dry-run by default. With --apply it verifies each tier's base Price against
the table (a mismatch aborts — wrong account mints nothing), backfills
tax_behavior while still unspecified (consumer inclusive / business
exclusive — the VAT split), creates the missing band Prices idempotently
(lookup keys eleven-<tier>-<band>; re-runs create nothing; amounts are
immutable in Stripe and the tool leans on that), and prints the complete new
LCHAT_STRIPE_PRICES for the vault flow — labels, eur, and per-band
net_eur_year from the documented EEA fee model.

Also trues up the doc's arithmetic: 19 Prices, not 20 — the nominal 5 × 4
minus C&F monthly's solidarity, which the fee-floor rule makes annual-only.
The Phasing section now records what's built (rails #1075, picker #1076,
this tool) vs the operational remainder (run it, seal the book, flip
LCHAT_STRIPE_AUTOMATIC_TAX with it).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 67583697e8379d1a911d40555c66fea29055ee2c parent 1a6d168
4 files changed, +253 −5
  • docs/pricing-bands.md +23 −4
  • package.json +1 −1
  • test/stripe-band-prices.test.mjs +57 −0
  • tools/stripe-band-prices.mjs +172 −0
diff --git a/docs/pricing-bands.md b/docs/pricing-bands.md
index 9554c07..02a0d5e 100644
--- a/docs/pricing-bands.md
+++ b/docs/pricing-bands.md
@@ -218,9 +218,23 @@ Phase 2 ships.
## Stripe implementation
-**20 Prices** (5 today × 4 bands), created once in the dashboard, ids pasted
-into `/data/cloud.env` via the vault flow. The price book grows one
-backward-compatible dimension (a tier with no `bands` behaves exactly as
+**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
+`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):
+
+```sh
+STRIPE_SECRET_KEY=sk_… node tools/stripe-band-prices.mjs --book current.json # dry-run
+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):
```json
@@ -332,13 +346,18 @@ The canonical cross-subsidy line (decided, aggregate form only):
## Phasing
-- **Phase 0 — bands, pure honor, no geolocation.** The 20 Prices; price-book
+- **Phase 0 — bands, pure honor, no geolocation.** The 19 Prices; price-book
`bands` + `net_eur_year`; `tierBandByPrice`; checkout `band` param; picker
with all four bands, Base pre-selected for everyone; deep-link band param;
local-currency ≈ display (ECB rates via home); Stripe Tax
(code + dashboard runbook above). Legally inert everywhere — it's four
prices — and already delivers "anyone, anywhere, one click to the lower
price" plus the supporter story. Measure band mix.
+ *Built (code side): the cloud rails (book/checkout/ledger/tax knob), the
+ home picker + deep links + ≈ display, and the minting tool. What remains is
+ operational: run the tool (sandbox, then live), seal the new book into the
+ vault, and flip `LCHAT_STRIPE_AUTOMATIC_TAX=1` with it — everything renders
+ as today until the book carries bands.*
- **Phase 1 — geo-suggested defaults.** Commit `tools/pricing-bands.mjs` +
the country→band table; pre-select by CDN country header (client-side,
never stored); EU/EEA pinned to Base; publish the table on the pricing
diff --git a/package.json b/package.json
index 6fd89da..13ce97e 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs"
+ "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node test/stripe-band-prices.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs"
}
}
diff --git a/test/stripe-band-prices.test.mjs b/test/stripe-band-prices.test.mjs
new file mode 100644
index 0000000..6da5ac9
--- /dev/null
+++ b/test/stripe-band-prices.test.mjs
@@ -0,0 +1,57 @@
+// Tests for the band-price minting tool's pure parts — the committed table
+// must stay the doc's matrix. node test/stripe-band-prices.test.mjs
+import assert from "node:assert/strict";
+import { BAND_TABLE, priceLabel, netEURYear } from "../tools/stripe-band-prices.mjs";
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log("PASS " + name);
+ } catch (e) {
+ console.log("FAIL " + name + " — " + (e && e.message));
+ failures++;
+ }
+}
+
+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).
+ const tiers = Object.keys(BAND_TABLE);
+ assert.equal(tiers.length, 5);
+ 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.
+ 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));
+ }
+});
+
+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("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);
+});
+
+console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/tools/stripe-band-prices.mjs b/tools/stripe-band-prices.mjs
new file mode 100644
index 0000000..068dd8d
--- /dev/null
+++ b/tools/stripe-band-prices.mjs
@@ -0,0 +1,172 @@
+#!/usr/bin/env node
+// 🤖 Mint the pricing-band 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):
+//
+// 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:
+//
+// 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).
+//
+// 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) -----------------
+
+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 } },
+};
+
+// 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);
+ const amount = euros.toLocaleString("en-IE", {
+ minimumFractionDigits: whole ? 0 : 2,
+ maximumFractionDigits: whole ? 0 : 2,
+ });
+ return "€" + amount + "/" + interval;
+}
+
+// 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;
+}
+
+// ---- Stripe plumbing ---------------------------------------------------------
+
+const API = process.env.STRIPE_API_BASE || "https://api.stripe.com";
+
+async function stripe(key, method, path, form) {
+ const res = await fetch(API + path, {
+ method,
+ headers: {
+ Authorization: "Bearer " + key,
+ ...(form ? { "Content-Type": "application/x-www-form-urlencoded" } : {}),
+ },
+ body: form ? new URLSearchParams(form).toString() : undefined,
+ });
+ const body = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ throw new Error(`${method} ${path}: HTTP ${res.status}: ${body.error?.message || "unknown error"}`);
+ }
+ return body;
+}
+
+async function main() {
+ const args = process.argv.slice(2);
+ const apply = args.includes("--apply");
+ const bookPath = args[args.indexOf("--book") + 1];
+ const key = process.env.STRIPE_SECRET_KEY;
+ if (!key || !bookPath || args.indexOf("--book") < 0) {
+ console.error("usage: STRIPE_SECRET_KEY=sk_… node tools/stripe-band-prices.mjs --book <current LCHAT_STRIPE_PRICES json file> [--apply]");
+ process.exit(2);
+ }
+ const { readFileSync } = await import("node:fs");
+ const book = JSON.parse(readFileSync(bookPath, "utf8"));
+ 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`);
+ }
+
+ // 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}`;
+ 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}`);
+ if (apply) {
+ const created = await stripe(key, "POST", "/v1/prices", {
+ product: base.product,
+ currency: "eur",
+ unit_amount: String(cents),
+ "recurring[interval]": spec.interval,
+ tax_behavior: behavior,
+ lookup_key: lookup,
+ "metadata[tier]": tier.key,
+ "metadata[band]": band,
+ });
+ priceID = created.id;
+ } else {
+ priceID = "price_DRYRUN_" + lookup;
+ }
+ }
+ bands[band] = {
+ label: priceLabel(cents, spec.interval),
+ price: priceID,
+ eur: cents / 100,
+ net_eur_year: netEURYear(cents, spec.interval),
+ };
+ }
+
+ // 4. The tier's new book entry: today's fields kept, the band dimension added.
+ const entry = {
+ ...tier,
+ eur: tier.eur || spec.base / 100,
+ net_eur_year: tier.net_eur_year ?? netEURYear(spec.base, spec.interval),
+ bands,
+ };
+ if (spec.business) entry.business = true;
+ out.push(entry);
+ }
+
+ console.error(apply ? "\nthe new LCHAT_STRIPE_PRICES (seal via the vault flow):" : "\nthe book a real run would produce:");
+ console.log(JSON.stringify(out));
+}
+
+// Import-safe: tests import the pure helpers without running the CLI.
+if (import.meta.url === "file://" + process.argv[1]) {
+ main().catch((e) => {
+ console.error(e.message || e);
+ process.exit(1);
+ });
+}