eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The Stripe companion app (docs/integrations.md § companion apps). Once
2// you've connected Stripe in Integrations (OAuth or a restricted key), this
3// app installs: a small dashboard — balance, recent charges, payouts, and
4// subscriptions — as its own thread.
5//
6// Same rail as the GitHub app: NO Stripe sidecar. Every fetch goes through
7// ctx.host.connected.requestOp, which seals your credential to the instance's
8// existing bot for one read-scoped op; what comes back is bounded by the
9// stripe manifest's `pick` projections, folds into state as typed events, and
10// syncs across your devices via snapshots. The server relays ciphertext
11// throughout. It's a TAKEOVER app (no chatMessage): the dashboard IS the
12// thread, so money data never echoes into chat bubbles or notifications.
13import { defineApp } from "../apps.js";
14import { LOGO_STRIPE } from "../logos.js";
15
16// Stripe amounts are integer minor units except in zero-decimal currencies.
17const ZERO_DECIMAL = new Set(["bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg", "rwf", "ugx", "vnd", "vuv", "xaf", "xof", "xpf"]);
18export function fmtMoney(amount, currency) {
19 if (typeof amount !== "number" || !currency) return "—";
20 const cur = String(currency).toLowerCase();
21 const value = ZERO_DECIMAL.has(cur) ? amount : amount / 100;
22 try {
23 return new Intl.NumberFormat(undefined, { style: "currency", currency: cur.toUpperCase() }).format(value);
24 } catch {
25 return `${value} ${cur.toUpperCase()}`;
26 }
27}
28
29// Results arrive pick-projected (flattened dot-path keys where nested) —
30// normalise once at fold time, drop anything malformed.
31const normMoneyRows = (list) =>
32 (Array.isArray(list) ? list : [])
33 .filter((r) => r && typeof r.amount === "number" && typeof r.currency === "string")
34 .map((r) => ({
35 id: typeof r.id === "string" ? r.id : "",
36 amount: r.amount,
37 currency: r.currency,
38 status: typeof r.status === "string" ? r.status : "",
39 refunded: !!r.refunded,
40 description: typeof r.description === "string" ? r.description : "",
41 created: typeof r.created === "number" ? r.created : 0,
42 arrival_date: typeof r.arrival_date === "number" ? r.arrival_date : 0,
43 }));
44
45const normBalance = (b) => {
46 const part = (list) =>
47 (Array.isArray(list) ? list : [])
48 .filter((e) => e && typeof e.amount === "number" && typeof e.currency === "string")
49 .map((e) => ({ amount: e.amount, currency: e.currency }));
50 return b && typeof b === "object" ? { available: part(b.available), pending: part(b.pending) } : { available: [], pending: [] };
51};
52
53const normSubs = (list) =>
54 (Array.isArray(list) ? list : [])
55 .filter((s) => s && typeof s.id === "string")
56 .map((s) => ({
57 id: s.id,
58 status: typeof s.status === "string" ? s.status : "",
59 customer: typeof s.customer === "string" ? s.customer : "",
60 ending: !!s.cancel_at_period_end,
61 period_end: typeof s.current_period_end === "number" ? s.current_period_end : 0,
62 }));
63
64// Unix-seconds → short local date ("12 Jul"); "" when absent.
65function day(sec) {
66 if (!sec) return "";
67 try {
68 return new Date(sec * 1000).toLocaleDateString(undefined, { day: "numeric", month: "short" });
69 } catch {
70 return "";
71 }
72}
73
74let busy = false;
75let autoLoading = false;
76
77export const app = defineApp({
78 name: "stripe",
79 title: "Stripe",
80 blurb: "Balance, charges & payouts",
81 glyph: "💳",
82 mark: "stripe", // native chrome: the vendored template asset iOS/Mac render (docs/icons.md)
83 logo: LOGO_STRIPE,
84 places: ["you"],
85 // Only listed in New chat once Stripe is linked (shell.js gates on this).
86 requiresService: "stripe",
87 initialState: () => ({ balance: null, charges: [], payouts: [], subs: [], fetchedAt: 0 }),
88
89 events: {
90 "stripe.balance": (s, e) => ({ ...s, balance: normBalance(e.balance), fetchedAt: e.ts || s.fetchedAt }),
91 "stripe.charges": (s, e) => (Array.isArray(e.charges) ? { ...s, charges: normMoneyRows(e.charges), fetchedAt: e.ts || s.fetchedAt } : s),
92 "stripe.payouts": (s, e) => (Array.isArray(e.payouts) ? { ...s, payouts: normMoneyRows(e.payouts), fetchedAt: e.ts || s.fetchedAt } : s),
93 "stripe.subs": (s, e) => (Array.isArray(e.subs) ? { ...s, subs: normSubs(e.subs), fetchedAt: e.ts || s.fetchedAt } : s),
94 },
95
96 styles: `
97.stp-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; }
98.stp-title { margin: 0; font-size: 18px; }
99.stp-sub { margin-left: auto; font-size: 12px; color: var(--muted); white-space: nowrap; }
100.stp-btn { flex: 0 0 auto; padding: 6px 12px; border-radius: 9px; border: 1px solid var(--border); background: var(--panel); color: var(--ink); font: inherit; cursor: var(--ui-cursor, pointer); }
101.stp-btn:disabled { opacity: .6; cursor: default; }
102.stp-tiles { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 16px; }
103.stp-tile { flex: 1 1 150px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 12px; }
104.stp-tile-label { font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
105.stp-tile-value { margin-top: 4px; font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
106.stp-h { margin: 18px 0 6px; font-size: 13px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: var(--muted); }
107.stp-scroll { overflow-x: auto; margin: 0 -4px; }
108.stp-table { width: 100%; border-collapse: collapse; font-size: 14px; }
109.stp-table th { text-align: left; font-weight: 600; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding: 6px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
110.stp-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
111.stp-amount { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; font-weight: 500; }
112.stp-desc { color: var(--muted); overflow-wrap: anywhere; }
113.stp-when { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
114.stp-badge { display: inline-block; font-size: 10px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; border: 1px solid var(--border); border-radius: 999px; padding: 1px 7px; white-space: nowrap; }
115.stp-badge.ok { color: #1a8f4c; border-color: #1a8f4c66; }
116.stp-badge.warn { color: #b26a00; border-color: #b26a0066; }
117.stp-empty { color: var(--muted); padding: 8px 0; }
118.stp-note { margin-top: 12px; min-height: 18px; font-size: 13px; color: var(--muted); }
119`,
120
121 render(state, el, ctx) {
122 const host = ctx.host && ctx.host.connected;
123 state = state || { balance: null, charges: [], payouts: [], subs: [], fetchedAt: 0 };
124
125 const head = document.createElement("div");
126 head.className = "stp-head";
127 el.appendChild(head);
128
129 const title = document.createElement("h2");
130 title.className = "stp-title";
131 title.textContent = "Stripe";
132 const sub = document.createElement("span");
133 sub.className = "stp-sub";
134
135 const note = document.createElement("div");
136 note.className = "stp-note";
137
138 if (!host || !host.requestOp) {
139 head.appendChild(title);
140 const empty = document.createElement("div");
141 empty.className = "stp-empty";
142 empty.textContent = "Stripe needs the web app for now — open this chat in a web browser (connecting isn't supported in the app yet).";
143 el.append(empty);
144 return;
145 }
146
147 // One Refresh pulls all four reads; each folds as its own event so a slow
148 // or failing one never blanks the rest. Whole body under try/finally so a
149 // throw can never wedge the module-level busy flag (the github.js lesson).
150 const refresh = async () => {
151 if (busy) return;
152 busy = true;
153 const errors = [];
154 try {
155 note.textContent = "";
156 paint();
157 const pulls = [
158 ["balance.get", (d) => ctx.emit({ type: "stripe.balance", balance: d })],
159 ["charges.list", (d) => ctx.emit({ type: "stripe.charges", charges: d })],
160 ["payouts.list", (d) => ctx.emit({ type: "stripe.payouts", payouts: d })],
161 ["subscriptions.list", (d) => ctx.emit({ type: "stripe.subs", subs: d })],
162 ];
163 await Promise.all(
164 pulls.map(async ([op, emit]) => {
165 let res;
166 try {
167 res = await host.requestOp("stripe", op, {});
168 } catch (e) {
169 res = { ok: false, error: (e && e.message) || String(e) };
170 }
171 if (res && res.ok && typeof res.data === "string") {
172 try {
173 emit(JSON.parse(res.data));
174 return;
175 } catch {}
176 }
177 errors.push(`${op}: ${(res && res.error) || "no response"}`);
178 }),
179 );
180 } finally {
181 busy = false;
182 autoLoading = false;
183 }
184 if (errors.length) note.textContent = `Couldn't refresh everything — ${errors[0]}`;
185 paint();
186 };
187
188 const refreshBtn = document.createElement("button");
189 refreshBtn.type = "button";
190 refreshBtn.className = "stp-btn";
191 refreshBtn.textContent = "↻ Refresh";
192 refreshBtn.onclick = refresh;
193 head.append(title, sub, refreshBtn);
194
195 // ---- balance tiles ------------------------------------------------------
196 const tiles = document.createElement("div");
197 tiles.className = "stp-tiles";
198 const tile = (label, entries) => {
199 const t = document.createElement("div");
200 t.className = "stp-tile";
201 const l = document.createElement("div");
202 l.className = "stp-tile-label";
203 l.textContent = label;
204 const v = document.createElement("div");
205 v.className = "stp-tile-value";
206 v.textContent = entries && entries.length ? entries.map((e) => fmtMoney(e.amount, e.currency)).join(" · ") : "—";
207 t.append(l, v);
208 return t;
209 };
210 if (state.balance) {
211 tiles.append(tile("Available", state.balance.available), tile("Pending", state.balance.pending));
212 el.appendChild(tiles);
213 }
214
215 // ---- tables -------------------------------------------------------------
216 const section = (heading, rows, cols, rowCells) => {
217 const h = document.createElement("div");
218 h.className = "stp-h";
219 h.textContent = heading;
220 el.appendChild(h);
221 if (!rows.length) {
222 const empty = document.createElement("div");
223 empty.className = "stp-empty";
224 empty.textContent = state.fetchedAt ? "Nothing here." : "Not loaded yet — hit Refresh.";
225 el.appendChild(empty);
226 return;
227 }
228 const scroll = document.createElement("div");
229 scroll.className = "stp-scroll";
230 const table = document.createElement("table");
231 table.className = "stp-table";
232 const thead = document.createElement("thead");
233 const hr = document.createElement("tr");
234 for (const c of cols) {
235 const th = document.createElement("th");
236 th.textContent = c;
237 hr.appendChild(th);
238 }
239 thead.appendChild(hr);
240 table.appendChild(thead);
241 const tbody = document.createElement("tbody");
242 for (const r of rows) {
243 const tr = document.createElement("tr");
244 for (const cell of rowCells(r)) tr.appendChild(cell);
245 tbody.appendChild(tr);
246 }
247 table.appendChild(tbody);
248 scroll.appendChild(table);
249 el.appendChild(scroll);
250 };
251
252 const td = (text, cls) => {
253 const c = document.createElement("td");
254 if (cls) c.className = cls;
255 c.textContent = text;
256 return c;
257 };
258 const badge = (text, ok) => {
259 const c = document.createElement("td");
260 const b = document.createElement("span");
261 b.className = "stp-badge" + (ok === true ? " ok" : ok === false ? " warn" : "");
262 b.textContent = text || "—";
263 c.appendChild(b);
264 return c;
265 };
266
267 section("Recent charges", state.charges, ["Amount", "Status", "Description", "When"], (r) => [
268 td(fmtMoney(r.amount, r.currency), "stp-amount"),
269 badge(r.refunded ? "refunded" : r.status, r.refunded ? false : r.status === "succeeded"),
270 td(r.description || "—", "stp-desc"),
271 td(day(r.created), "stp-when"),
272 ]);
273 section("Payouts", state.payouts, ["Amount", "Status", "Arrives", "Created"], (r) => [
274 td(fmtMoney(r.amount, r.currency), "stp-amount"),
275 badge(r.status, r.status === "paid"),
276 td(day(r.arrival_date), "stp-when"),
277 td(day(r.created), "stp-when"),
278 ]);
279 section("Subscriptions", state.subs, ["Subscription", "Status", "Renews"], (r) => [
280 td(r.id, "stp-desc"),
281 badge(r.ending ? "ending" : r.status, r.ending ? false : r.status === "active"),
282 td(day(r.period_end), "stp-when"),
283 ]);
284
285 el.appendChild(note);
286
287 const paint = () => {
288 sub.textContent = busy ? "loading…" : state.fetchedAt ? `updated ${new Date(state.fetchedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "";
289 refreshBtn.disabled = busy;
290 };
291 paint();
292
293 // First open pulls once, automatically — after the surface is fully built
294 // (never mid-render; see the github.js temporal-dead-zone lesson).
295 if (!state.fetchedAt && !busy && !autoLoading) {
296 autoLoading = true;
297 refresh();
298 }
299 },
300});
301