| 1 | #!/usr/bin/env node |
| 2 | // π€ Mint the elevens-ladder Stripe Prices (docs/pricing-bands.md Β§ Stripe |
| 3 | // implementation) and emit the new LCHAT_STRIPE_PRICES book. |
| 4 | // |
| 5 | // The band table below IS the committed, reviewable artifact β the doc's |
| 6 | // charm-rounded matrix, in decimal cents. The operator runs this from their |
| 7 | // own terminal (the secret key never transits a session or the repo): |
| 8 | // |
| 9 | // STRIPE_SECRET_KEY=sk_test_β¦ node tools/stripe-band-prices.mjs --book current.json |
| 10 | // STRIPE_SECRET_KEY=sk_test_β¦ node tools/stripe-band-prices.mjs --book current.json --apply |
| 11 | // |
| 12 | // where current.json holds today's LCHAT_STRIPE_PRICES value β consulted only |
| 13 | // to reuse each tier's existing Stripe product, so subscriber dashboards keep |
| 14 | // their product history. Dry-run by default: it prints every action it WOULD |
| 15 | // take. With --apply it: |
| 16 | // |
| 17 | // 1. resolves each ladder tier's product from the current book (first key in |
| 18 | // productFrom that's present), creating the product only for a tier the |
| 19 | // account has never sold, |
| 20 | // 2. creates every missing ladder Price β base and bands β under lookup_key |
| 21 | // eleven11-<tier>-<band>, the idempotency handle: a re-run finds them and |
| 22 | // creates nothing. The old eleven-* prices are left untouched (existing |
| 23 | // subscriptions keep renewing on them until they lapse), |
| 24 | // 3. prints the complete new price-book JSON, ready for the env flip |
| 25 | // (labels + eur + net_eur_year per band; per_seat/min_seats/business |
| 26 | // flags set). |
| 27 | // |
| 28 | // Per-unit tiers (community per member, business per seat) use |
| 29 | // unit_amount_decimal β Stripe takes fractional cents, which is how the |
| 30 | // reduced/solidarity bands of a β¬0.11 price exist at all. Test first with a |
| 31 | // sandbox key; the live run is the same command with the live key. Nothing |
| 32 | // here deletes or mutates amounts β existing Prices are immutable in Stripe |
| 33 | // and this tool leans on that. |
| 34 | |
| 35 | // ---- the table (docs/pricing-bands.md Β§ The bands, in decimal cents) --------- |
| 36 | // |
| 37 | // The whole ladder is annual-only (the fixed ~β¬0.25/charge makes small |
| 38 | // monthly prices fee-hostile). unit: null = flat per space; "member"/"seat" = |
| 39 | // per-unit, billed as checkout quantity. minSeats is the included-members |
| 40 | // identity: β¬0.11 Γ 100 IS the β¬11 minimum, so the floor needs no separate |
| 41 | // Price and every band scales it for free. |
| 42 | |
| 43 | export const BAND_TABLE = { |
| 44 | family: { |
| 45 | name: "Family", productFrom: ["family"], business: false, unit: null, |
| 46 | base: "550", bands: { supporter: "770", reduced: "275", solidarity: "140" }, |
| 47 | }, |
| 48 | community: { |
| 49 | name: "Community & Friends", productFrom: ["friends-year", "friends", "community"], |
| 50 | business: false, unit: "member", minSeats: 100, |
| 51 | base: "11", bands: { supporter: "15", reduced: "5.5", solidarity: "2.75" }, |
| 52 | }, |
| 53 | business: { |
| 54 | name: "Business", productFrom: ["business-year", "business"], |
| 55 | business: true, unit: "seat", |
| 56 | base: "1100", bands: { supporter: "1500", reduced: "550" }, |
| 57 | }, |
| 58 | }; |
| 59 | |
| 60 | // priceLabel renders a decimal-cent amount as the book's display label: |
| 61 | // "β¬5.50/year", "β¬0.11/member/year", "β¬0.055/member/year" β whole euros bare, |
| 62 | // cents as a pair, sub-cent per-unit rates to three places. |
| 63 | export function priceLabel(cents, unit) { |
| 64 | const euros = Number(cents) / 100; |
| 65 | const digits = Number.isInteger(euros) ? 0 : euros * 100 === Math.floor(euros * 100) ? 2 : 3; |
| 66 | const amount = euros.toLocaleString("en-IE", { |
| 67 | minimumFractionDigits: digits, |
| 68 | maximumFractionDigits: digits, |
| 69 | }); |
| 70 | return "β¬" + amount + "/" + (unit ? unit + "/" : "") + "year"; |
| 71 | } |
| 72 | |
| 73 | // netEURYear estimates what a price nets per year after Stripe fees β the |
| 74 | // free pool's revenue input, per space for flat tiers and per unit for |
| 75 | // per-unit ones (the ~β¬0.25 fixed fee lands once per space's single annual |
| 76 | // charge, so flat tiers absorb it whole and per-unit rates treat it as |
| 77 | // space-level noise). An estimate, stated as such: docs/free-pool.md |
| 78 | // tolerates the imprecision. |
| 79 | export function netEURYear(cents, unit) { |
| 80 | const gross = Number(cents) / 100; |
| 81 | const net = unit ? gross - gross * 0.015 : gross - gross * 0.015 - 0.25; |
| 82 | return Math.round(net * 1000) / 1000; |
| 83 | } |
| 84 | |
| 85 | // ---- Stripe plumbing --------------------------------------------------------- |
| 86 | |
| 87 | const API = process.env.STRIPE_API_BASE || "https://api.stripe.com"; |
| 88 | |
| 89 | async function stripe(key, method, path, form) { |
| 90 | const res = await fetch(API + path, { |
| 91 | method, |
| 92 | headers: { |
| 93 | Authorization: "Bearer " + key, |
| 94 | ...(form ? { "Content-Type": "application/x-www-form-urlencoded" } : {}), |
| 95 | }, |
| 96 | body: form ? new URLSearchParams(form).toString() : undefined, |
| 97 | }); |
| 98 | const body = await res.json().catch(() => ({})); |
| 99 | if (!res.ok) { |
| 100 | throw new Error(`${method} ${path}: HTTP ${res.status}: ${body.error?.message || "unknown error"}`); |
| 101 | } |
| 102 | return body; |
| 103 | } |
| 104 | |
| 105 | async function main() { |
| 106 | const args = process.argv.slice(2); |
| 107 | const apply = args.includes("--apply"); |
| 108 | const bookPath = args[args.indexOf("--book") + 1]; |
| 109 | const key = process.env.STRIPE_SECRET_KEY; |
| 110 | if (!key || !bookPath || args.indexOf("--book") < 0) { |
| 111 | console.error("usage: STRIPE_SECRET_KEY=sk_β¦ node tools/stripe-band-prices.mjs --book <current LCHAT_STRIPE_PRICES json file> [--apply]"); |
| 112 | process.exit(2); |
| 113 | } |
| 114 | const { readFileSync } = await import("node:fs"); |
| 115 | const book = JSON.parse(readFileSync(bookPath, "utf8")); |
| 116 | const act = apply ? "" : "[dry-run] "; |
| 117 | const out = []; |
| 118 | |
| 119 | for (const [tierKey, spec] of Object.entries(BAND_TABLE)) { |
| 120 | // 1. Reuse the tier's existing product so subscriber history stays on one |
| 121 | // product; a never-sold tier gets a fresh one. |
| 122 | const from = book.find((t) => spec.productFrom.includes(t.key)); |
| 123 | let product; |
| 124 | if (from) { |
| 125 | const price = await stripe(key, "GET", "/v1/prices/" + from.price); |
| 126 | product = price.product; |
| 127 | console.error(`${act}${tierKey}: product ${product} (via book tier ${from.key})`); |
| 128 | } else if (apply) { |
| 129 | const created = await stripe(key, "POST", "/v1/products", { name: "Eleven " + spec.name }); |
| 130 | product = created.id; |
| 131 | console.error(`${act}${tierKey}: created product ${product}`); |
| 132 | } else { |
| 133 | product = "prod_DRYRUN_" + tierKey; |
| 134 | console.error(`${act}${tierKey}: would create product "Eleven ${spec.name}"`); |
| 135 | } |
| 136 | |
| 137 | // 2. Base + bands, each found-or-created under its versioned lookup key. |
| 138 | const behavior = spec.business ? "exclusive" : "inclusive"; |
| 139 | const mint = async (band, cents) => { |
| 140 | const lookup = `eleven11-${tierKey}-${band}`; |
| 141 | const existing = await stripe(key, "GET", "/v1/prices?lookup_keys[]=" + encodeURIComponent(lookup) + "&limit=1"); |
| 142 | let priceID = existing.data?.[0]?.id; |
| 143 | if (priceID) { |
| 144 | console.error(`${act}${lookup}: exists (${priceID})`); |
| 145 | } else { |
| 146 | console.error(`${act}${lookup}: create ${cents}c eur/year tax_behavior=${behavior} on ${product}`); |
| 147 | if (apply) { |
| 148 | const created = await stripe(key, "POST", "/v1/prices", { |
| 149 | product, |
| 150 | currency: "eur", |
| 151 | unit_amount_decimal: String(cents), |
| 152 | "recurring[interval]": "year", |
| 153 | tax_behavior: behavior, |
| 154 | lookup_key: lookup, |
| 155 | "metadata[tier]": tierKey, |
| 156 | "metadata[band]": band, |
| 157 | }); |
| 158 | priceID = created.id; |
| 159 | } else { |
| 160 | priceID = "price_DRYRUN_" + lookup; |
| 161 | } |
| 162 | } |
| 163 | return { |
| 164 | label: priceLabel(cents, spec.unit), |
| 165 | price: priceID, |
| 166 | eur: Number(cents) / 100, |
| 167 | net_eur_year: netEURYear(cents, spec.unit), |
| 168 | }; |
| 169 | }; |
| 170 | |
| 171 | const base = await mint("base", spec.base); |
| 172 | const bands = {}; |
| 173 | for (const [band, cents] of Object.entries(spec.bands)) { |
| 174 | bands[band] = await mint(band, cents); |
| 175 | } |
| 176 | |
| 177 | // 3. The tier's book entry, flags included. |
| 178 | const entry = { |
| 179 | key: tierKey, |
| 180 | name: spec.name, |
| 181 | label: base.label, |
| 182 | price: base.price, |
| 183 | eur: base.eur, |
| 184 | net_eur_year: base.net_eur_year, |
| 185 | bands, |
| 186 | }; |
| 187 | if (spec.business) entry.business = true; |
| 188 | if (spec.unit) entry.per_seat = true; |
| 189 | if (spec.minSeats) entry.min_seats = spec.minSeats; |
| 190 | out.push(entry); |
| 191 | } |
| 192 | |
| 193 | console.error(apply ? "\nthe new LCHAT_STRIPE_PRICES (seal via the vault flow):" : "\nthe book a real run would produce:"); |
| 194 | console.log(JSON.stringify(out)); |
| 195 | } |
| 196 | |
| 197 | // Import-safe: tests import the pure helpers without running the CLI. |
| 198 | if (import.meta.url === "file://" + process.argv[1]) { |
| 199 | main().catch((e) => { |
| 200 | console.error(e.message || e); |
| 201 | process.exit(1); |
| 202 | }); |
| 203 | } |
| 204 | |