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>
4 files changed,
+253
−5
docs/pricing-bands.md+23 −4package.json+1 −1test/stripe-band-prices.test.mjs+57 −0tools/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); |
| + }); |
| +} |