// The Stripe companion app (docs/integrations.md § companion apps). Once // you've connected Stripe in Integrations (OAuth or a restricted key), this // app installs: a small dashboard — balance, recent charges, payouts, and // subscriptions — as its own thread. // // Same rail as the GitHub app: NO Stripe sidecar. Every fetch goes through // ctx.host.connected.requestOp, which seals your credential to the instance's // existing bot for one read-scoped op; what comes back is bounded by the // stripe manifest's `pick` projections, folds into state as typed events, and // syncs across your devices via snapshots. The server relays ciphertext // throughout. It's a TAKEOVER app (no chatMessage): the dashboard IS the // thread, so money data never echoes into chat bubbles or notifications. import { defineApp } from "../apps.js"; import { LOGO_STRIPE } from "../logos.js"; // Stripe amounts are integer minor units except in zero-decimal currencies. const ZERO_DECIMAL = new Set(["bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg", "rwf", "ugx", "vnd", "vuv", "xaf", "xof", "xpf"]); export function fmtMoney(amount, currency) { if (typeof amount !== "number" || !currency) return "—"; const cur = String(currency).toLowerCase(); const value = ZERO_DECIMAL.has(cur) ? amount : amount / 100; try { return new Intl.NumberFormat(undefined, { style: "currency", currency: cur.toUpperCase() }).format(value); } catch { return `${value} ${cur.toUpperCase()}`; } } // Results arrive pick-projected (flattened dot-path keys where nested) — // normalise once at fold time, drop anything malformed. const normMoneyRows = (list) => (Array.isArray(list) ? list : []) .filter((r) => r && typeof r.amount === "number" && typeof r.currency === "string") .map((r) => ({ id: typeof r.id === "string" ? r.id : "", amount: r.amount, currency: r.currency, status: typeof r.status === "string" ? r.status : "", refunded: !!r.refunded, description: typeof r.description === "string" ? r.description : "", created: typeof r.created === "number" ? r.created : 0, arrival_date: typeof r.arrival_date === "number" ? r.arrival_date : 0, })); const normBalance = (b) => { const part = (list) => (Array.isArray(list) ? list : []) .filter((e) => e && typeof e.amount === "number" && typeof e.currency === "string") .map((e) => ({ amount: e.amount, currency: e.currency })); return b && typeof b === "object" ? { available: part(b.available), pending: part(b.pending) } : { available: [], pending: [] }; }; const normSubs = (list) => (Array.isArray(list) ? list : []) .filter((s) => s && typeof s.id === "string") .map((s) => ({ id: s.id, status: typeof s.status === "string" ? s.status : "", customer: typeof s.customer === "string" ? s.customer : "", ending: !!s.cancel_at_period_end, period_end: typeof s.current_period_end === "number" ? s.current_period_end : 0, })); // Unix-seconds → short local date ("12 Jul"); "" when absent. function day(sec) { if (!sec) return ""; try { return new Date(sec * 1000).toLocaleDateString(undefined, { day: "numeric", month: "short" }); } catch { return ""; } } let busy = false; let autoLoading = false; export const app = defineApp({ name: "stripe", title: "Stripe", blurb: "Balance, charges & payouts", glyph: "💳", mark: "stripe", // native chrome: the vendored template asset iOS/Mac render (docs/icons.md) logo: LOGO_STRIPE, places: ["you"], // Only listed in New chat once Stripe is linked (shell.js gates on this). requiresService: "stripe", initialState: () => ({ balance: null, charges: [], payouts: [], subs: [], fetchedAt: 0 }), events: { "stripe.balance": (s, e) => ({ ...s, balance: normBalance(e.balance), fetchedAt: e.ts || s.fetchedAt }), "stripe.charges": (s, e) => (Array.isArray(e.charges) ? { ...s, charges: normMoneyRows(e.charges), fetchedAt: e.ts || s.fetchedAt } : s), "stripe.payouts": (s, e) => (Array.isArray(e.payouts) ? { ...s, payouts: normMoneyRows(e.payouts), fetchedAt: e.ts || s.fetchedAt } : s), "stripe.subs": (s, e) => (Array.isArray(e.subs) ? { ...s, subs: normSubs(e.subs), fetchedAt: e.ts || s.fetchedAt } : s), }, styles: ` .stp-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; } .stp-title { margin: 0; font-size: 18px; } .stp-sub { margin-left: auto; font-size: 12px; color: var(--muted); white-space: nowrap; } .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); } .stp-btn:disabled { opacity: .6; cursor: default; } .stp-tiles { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 16px; } .stp-tile { flex: 1 1 150px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 12px; } .stp-tile-label { font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); } .stp-tile-value { margin-top: 4px; font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } .stp-h { margin: 18px 0 6px; font-size: 13px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: var(--muted); } .stp-scroll { overflow-x: auto; margin: 0 -4px; } .stp-table { width: 100%; border-collapse: collapse; font-size: 14px; } .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; } .stp-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; } .stp-amount { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; font-weight: 500; } .stp-desc { color: var(--muted); overflow-wrap: anywhere; } .stp-when { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; } .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; } .stp-badge.ok { color: #1a8f4c; border-color: #1a8f4c66; } .stp-badge.warn { color: #b26a00; border-color: #b26a0066; } .stp-empty { color: var(--muted); padding: 8px 0; } .stp-note { margin-top: 12px; min-height: 18px; font-size: 13px; color: var(--muted); } `, render(state, el, ctx) { const host = ctx.host && ctx.host.connected; state = state || { balance: null, charges: [], payouts: [], subs: [], fetchedAt: 0 }; const head = document.createElement("div"); head.className = "stp-head"; el.appendChild(head); const title = document.createElement("h2"); title.className = "stp-title"; title.textContent = "Stripe"; const sub = document.createElement("span"); sub.className = "stp-sub"; const note = document.createElement("div"); note.className = "stp-note"; if (!host || !host.requestOp) { head.appendChild(title); const empty = document.createElement("div"); empty.className = "stp-empty"; empty.textContent = "Stripe needs the web app for now — open this chat in a web browser (connecting isn't supported in the app yet)."; el.append(empty); return; } // One Refresh pulls all four reads; each folds as its own event so a slow // or failing one never blanks the rest. Whole body under try/finally so a // throw can never wedge the module-level busy flag (the github.js lesson). const refresh = async () => { if (busy) return; busy = true; const errors = []; try { note.textContent = ""; paint(); const pulls = [ ["balance.get", (d) => ctx.emit({ type: "stripe.balance", balance: d })], ["charges.list", (d) => ctx.emit({ type: "stripe.charges", charges: d })], ["payouts.list", (d) => ctx.emit({ type: "stripe.payouts", payouts: d })], ["subscriptions.list", (d) => ctx.emit({ type: "stripe.subs", subs: d })], ]; await Promise.all( pulls.map(async ([op, emit]) => { let res; try { res = await host.requestOp("stripe", op, {}); } catch (e) { res = { ok: false, error: (e && e.message) || String(e) }; } if (res && res.ok && typeof res.data === "string") { try { emit(JSON.parse(res.data)); return; } catch {} } errors.push(`${op}: ${(res && res.error) || "no response"}`); }), ); } finally { busy = false; autoLoading = false; } if (errors.length) note.textContent = `Couldn't refresh everything — ${errors[0]}`; paint(); }; const refreshBtn = document.createElement("button"); refreshBtn.type = "button"; refreshBtn.className = "stp-btn"; refreshBtn.textContent = "↻ Refresh"; refreshBtn.onclick = refresh; head.append(title, sub, refreshBtn); // ---- balance tiles ------------------------------------------------------ const tiles = document.createElement("div"); tiles.className = "stp-tiles"; const tile = (label, entries) => { const t = document.createElement("div"); t.className = "stp-tile"; const l = document.createElement("div"); l.className = "stp-tile-label"; l.textContent = label; const v = document.createElement("div"); v.className = "stp-tile-value"; v.textContent = entries && entries.length ? entries.map((e) => fmtMoney(e.amount, e.currency)).join(" · ") : "—"; t.append(l, v); return t; }; if (state.balance) { tiles.append(tile("Available", state.balance.available), tile("Pending", state.balance.pending)); el.appendChild(tiles); } // ---- tables ------------------------------------------------------------- const section = (heading, rows, cols, rowCells) => { const h = document.createElement("div"); h.className = "stp-h"; h.textContent = heading; el.appendChild(h); if (!rows.length) { const empty = document.createElement("div"); empty.className = "stp-empty"; empty.textContent = state.fetchedAt ? "Nothing here." : "Not loaded yet — hit Refresh."; el.appendChild(empty); return; } const scroll = document.createElement("div"); scroll.className = "stp-scroll"; const table = document.createElement("table"); table.className = "stp-table"; const thead = document.createElement("thead"); const hr = document.createElement("tr"); for (const c of cols) { const th = document.createElement("th"); th.textContent = c; hr.appendChild(th); } thead.appendChild(hr); table.appendChild(thead); const tbody = document.createElement("tbody"); for (const r of rows) { const tr = document.createElement("tr"); for (const cell of rowCells(r)) tr.appendChild(cell); tbody.appendChild(tr); } table.appendChild(tbody); scroll.appendChild(table); el.appendChild(scroll); }; const td = (text, cls) => { const c = document.createElement("td"); if (cls) c.className = cls; c.textContent = text; return c; }; const badge = (text, ok) => { const c = document.createElement("td"); const b = document.createElement("span"); b.className = "stp-badge" + (ok === true ? " ok" : ok === false ? " warn" : ""); b.textContent = text || "—"; c.appendChild(b); return c; }; section("Recent charges", state.charges, ["Amount", "Status", "Description", "When"], (r) => [ td(fmtMoney(r.amount, r.currency), "stp-amount"), badge(r.refunded ? "refunded" : r.status, r.refunded ? false : r.status === "succeeded"), td(r.description || "—", "stp-desc"), td(day(r.created), "stp-when"), ]); section("Payouts", state.payouts, ["Amount", "Status", "Arrives", "Created"], (r) => [ td(fmtMoney(r.amount, r.currency), "stp-amount"), badge(r.status, r.status === "paid"), td(day(r.arrival_date), "stp-when"), td(day(r.created), "stp-when"), ]); section("Subscriptions", state.subs, ["Subscription", "Status", "Renews"], (r) => [ td(r.id, "stp-desc"), badge(r.ending ? "ending" : r.status, r.ending ? false : r.status === "active"), td(day(r.period_end), "stp-when"), ]); el.appendChild(note); const paint = () => { sub.textContent = busy ? "loading…" : state.fetchedAt ? `updated ${new Date(state.fetchedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : ""; refreshBtn.disabled = busy; }; paint(); // First open pulls once, automatically — after the surface is fully built // (never mid-render; see the github.js temporal-dead-zone lesson). if (!state.fetchedAt && !busy && !autoLoading) { autoLoading = true; refresh(); } }, });