#!/usr/bin/env node // 🤖 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 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 — 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. 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--, 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). // // 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 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: { 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 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: digits, maximumFractionDigits: digits, }); return "€" + amount + "/" + (unit ? unit + "/" : "") + "year"; } // 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 --------------------------------------------------------- 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 [--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 [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}"`); } // 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/year tax_behavior=${behavior} on ${product}`); if (apply) { const created = await stripe(key, "POST", "/v1/prices", { product, currency: "eur", unit_amount_decimal: String(cents), "recurring[interval]": "year", tax_behavior: behavior, lookup_key: lookup, "metadata[tier]": tierKey, "metadata[band]": band, }); priceID = created.id; } else { priceID = "price_DRYRUN_" + lookup; } } return { label: priceLabel(cents, spec.unit), price: priceID, 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); } // 3. The tier's book entry, flags included. const entry = { 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); } 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); }); }