| new file mode 100644 |
| index 0000000..d5faca4 |
| --- /dev/null |
| +++ b/web/static/apps/stripe.js |
| @@ -0,0 +1,297 @@ |
| +// 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"; |
| + |
| +// 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: "💳", |
| + scope: "personal", |
| + // 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(); |
| + } |
| + }, |
| +}); |