eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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
43export 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.
63export 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.
79export 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
87const API = process.env.STRIPE_API_BASE || "https://api.stripe.com";
88
89async 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
105async 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.
198if (import.meta.url === "file://" + process.argv[1]) {
199 main().catch((e) => {
200 console.error(e.message || e);
201 process.exit(1);
202 });
203}
204