| 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. |
| 13 | import { defineApp } from "../apps.js"; |
| 14 | import { LOGO_STRIPE } from "../logos.js"; |
| 15 | |
| 16 | // Stripe amounts are integer minor units except in zero-decimal currencies. |
| 17 | const ZERO_DECIMAL = new Set(["bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg", "rwf", "ugx", "vnd", "vuv", "xaf", "xof", "xpf"]); |
| 18 | export 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. |
| 31 | const 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 | |
| 45 | const 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 | |
| 53 | const 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. |
| 65 | function 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 | |
| 74 | let busy = false; |
| 75 | let autoLoading = false; |
| 76 | |
| 77 | export 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 | |