Public: anyone can read this; the crew can change it.
The admin Billing page shows the last Stripe event received and when, and how many signatures were refused #11291
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11248-the-admin-billing-page-shows-the-last-st → main 5 commits fork 3 threads · 3 unresolved
7 files
changed +72
−2
docs/features/stripe-events-line.md added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | # The last Stripe event and refused signatures | |
| 2 | ||
| 3 | An admin who is a person can see when Stripe last told the forge something, and how many events were refused for a bad or stale signature on the current UTC day. | |
| 4 | ||
| 5 | - **Page:** `/settings/billing`, under Provider: two rows, "Last event" (its type and time in UTC, or "No event yet") and "Refused today". | |
| 6 | - **Terminal:** the Billing screen (palette: Billing) shows the same two facts in a line under the Stripe status. | |
| 7 | ||
| 8 | Both read `readBilling` (`events`), so both are admin-only: anyone else gets the not-found page, and the terminal screen shows nothing. The data comes from `POST /hooks/stripe` (`stripe_event`, `stripe_hook_refused`). The count is the current UTC day, not a rolling 24 hours. |
src/actions/billing.ts +4
| @@ -12,6 +12,7 @@ import type { Entitlement } from "../billing/types.ts"; | ||
| 12 | 12 | import type { User } from "../core/users.ts"; |
| 13 | 13 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 14 | 14 | import { connectionOf, forget, openKey, sealKey, sealWebhook, type Connection } from "../billing/connection.ts"; |
| 15 | import { hookSummary } from "../http/stripe_hook.ts"; | |
| 15 | 16 | import { isStripeKey, stripeClient, type StripeError } from "../billing/stripe.ts"; |
| 16 | 17 | import { publicOrigin } from "../core/public_origin.ts"; |
| 17 | 18 | import { holdValues } from "../kernel/log.ts"; |
| @@ -41,6 +42,8 @@ export interface BillingView { | ||
| 41 | 42 | readonly forges: readonly (Entitlement & { size: string })[]; |
| 42 | 43 | /** The Stripe connection (mode, whether events are signed), never the key. */ |
| 43 | 44 | readonly stripe: Connection | undefined; |
| 45 | /** The newest Stripe event kept and the signatures refused on this UTC day. */ | |
| 46 | readonly events: ReturnType<typeof hookSummary>; | |
| 44 | 47 | } |
| 45 | 48 | |
| 46 | 49 | const admin = (a: User | undefined): Result<User, "not_found"> => (a?.isAdmin === true && a.kind !== "agent" ? ok(a) : err("not_found")); |
| @@ -51,6 +54,7 @@ export function readBilling(app: App, actor: User | undefined): Result<BillingVi | ||
| 51 | 54 | return ok({ |
| 52 | 55 | forges: listChildrenNow(app).map((c) => ({ ...entitlementOf(app.db, c.name, c.size), size: c.size })), |
| 53 | 56 | stripe: connectionOf(app.db), |
| 57 | events: hookSummary(app), | |
| 54 | 58 | }); |
| 55 | 59 | } |
| 56 | 60 |
src/http/stripe_hook.ts +8
| @@ -57,6 +57,14 @@ export function refusedCount(app: App, kind: "bad" | "stale"): number { | ||
| 57 | 57 | ); |
| 58 | 58 | } |
| 59 | 59 | |
| 60 | /** The newest event kept, and the signatures refused on this UTC day: what the admin Billing page says. */ | |
| 61 | export function hookSummary(app: App, now = new Date()): { last: { type: string; at: string } | undefined; refusedToday: number } { | |
| 62 | const last = app.db.query<{ type: string; at: string }, []>("SELECT type, at FROM stripe_event ORDER BY at DESC LIMIT 1").get() ?? undefined; | |
| 63 | const refusedToday = | |
| 64 | app.db.query<{ n: number }, [string]>("SELECT COALESCE(SUM(n), 0) AS n FROM stripe_hook_refused WHERE day = ?1").get(now.toISOString().slice(0, 10))?.n ?? 0; | |
| 65 | return { last, refusedToday }; | |
| 66 | } | |
| 67 | ||
| 60 | 68 | function countRefused(app: App, kind: "bad" | "stale", now: Date): void { |
| 61 | 69 | const row = app.db |
| 62 | 70 | .query<{ n: number }, [string, string]>( |
src/tui/screens/billing.ts +5 −2
| @@ -41,11 +41,14 @@ export function billingScreen(f: Session, ctx: Ctx): Screen { | ||
| 41 | 41 | state.clamp(rows.length); |
| 42 | 42 | const sc = readBilling(f.app, f.user); |
| 43 | 43 | const stripe = sc.ok && sc.value.stripe ? `Stripe ${sc.value.stripe.mode}, ${sc.value.stripe.signed ? "signed" : "no signing secret"}` : "Stripe not connected"; |
| 44 | const ev = sc.ok ? sc.value.events : undefined; | |
| 45 | const events = ev === undefined ? "" : `Last event: ${ev.last === undefined ? "No event yet" : `${ev.last.type} ${ev.last.at.slice(0, 16).replace("T", " ")} UTC`}. Refused today: ${ev.refusedToday}`; | |
| 44 | 46 | if (rows.length === 0) { |
| 45 | emptyState(s, area, c, { title: "No forge has been sent from here.", body: note === "" ? `Billing has nothing to grant yet. ${stripe}.` : note }); | |
| 47 | emptyState(s, area, c, { title: "No forge has been sent from here.", body: note === "" ? `Billing has nothing to grant yet. ${stripe}. ${events}.` : note }); | |
| 46 | 48 | return; |
| 47 | 49 | } |
| 48 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 50 | const [head, evHead, body] = split(area, "v", [len(1), len(1), fill(1)]); | |
| 51 | s.text(evHead!.x, evHead!.y, truncate(events, evHead!.w), style(c.mutedFg, c.bg), evHead!.x + evHead!.w); | |
| 49 | 52 | const line = note === "" ? `${stripe} (c connect, w secret, x disconnect)` : note; |
| 50 | 53 | s.text(head!.x, head!.y, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 51 | 54 | drawList(s, body!, c, state, rows); |
src/web/pages/billing.ts +6
| @@ -160,8 +160,14 @@ function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = { | ||
| 160 | 160 | ); |
| 161 | 161 | const rendering = performance.now(); |
| 162 | 162 | const s = b.stripe; |
| 163 | const hook = b.events; | |
| 164 | const hookRows = html`<dl class="status-rows" data-stripe-events> | |
outdated
Principle 7: a fact is a |
||
| 165 | <div class="status-row"><dt>Last event</dt><dd>${hook.last === undefined ? "No event yet" : html`<span class="mono">${hook.last.type}</span> <time datetime="${hook.last.at}">${hook.last.at.slice(0, 16).replace("T", " ")} UTC</time>`}</dd></div> | |
| 166 | <div class="status-row"><dt>Refused today</dt><dd>${hook.refusedToday}</dd></div> | |
| 167 | </dl>`; | |
| 163 | 168 | const stripe = html`<section class="settings-block"> |
| 164 | 169 | <h2>Provider</h2> |
| 170 | ${hookRows} | |
| 165 | 171 | ${s |
| 166 | 172 | ? html`<dl class="status-rows"> |
| 167 | 173 | <div class="status-row"><dt>Stripe</dt><dd data-mode="${s.mode}">${s.mode}</dd></div> |
test/billing-tui.test.ts +18
| @@ -158,3 +158,21 @@ test("a bad key typed into the connect prompt is refused in words; a non-admin c | ||
| 158 | 158 | expect(bobs.tui.screen.toText()).not.toContain("Remove the webhook"); |
| 159 | 159 | bobs.tui.stop?.(); |
| 160 | 160 | }); |
| 161 | ||
| 162 | test("the terminal Billing screen shows the last Stripe event and refusals to an admin, and nothing to a non-admin", async () => { | |
| 163 | const now = new Date().toISOString(); | |
| 164 | app.db.query("INSERT OR IGNORE INTO stripe_event (id, type, at) VALUES ('evt_tui', 'invoice.paid', ?1)").run(now); | |
| 165 | app.db.query("INSERT OR IGNORE INTO stripe_hook_refused (day, kind, n) VALUES (?1, 'bad', 2)").run(now.slice(0, 10)); | |
| 166 | const mine = open(alice); | |
| 167 | mine.tui.ctx.push(billingScreen(mine.session, mine.tui.ctx)); | |
| 168 | await settleApp(mine.tui); | |
| 169 | expect(mine.tui.screen.toText()).toContain("Last event: invoice.paid"); | |
| 170 | expect(mine.tui.screen.toText()).toContain("Refused today: 2"); | |
| 171 | mine.tui.stop?.(); | |
| 172 | const bobs = open(bob); | |
| 173 | bobs.tui.ctx.push(billingScreen(bobs.session, bobs.tui.ctx)); | |
| 174 | await settleApp(bobs.tui); | |
| 175 | expect(bobs.tui.screen.toText()).not.toContain("invoice.paid"); | |
| 176 | expect(bobs.tui.screen.toText()).not.toContain("Refused today"); | |
| 177 | bobs.tui.stop?.(); | |
| 178 | }); |
test/billing.test.ts +23
| @@ -141,4 +141,27 @@ describe("billing seam", () => { | ||
| 141 | 141 | expect(text).toContain('data-forge="kid"'); |
| 142 | 142 | expect((await send("GET", "/settings/billing", bob)).status).toBe(404); |
| 143 | 143 | }); |
| 144 | ||
| 145 | test("the Billing page says the last Stripe event and the refused signatures, or that none came", async () => { | |
| 146 | const before = await (await send("GET", "/settings/billing", admin)).text(); | |
| 147 | expect(before).toContain("No event yet"); | |
| 148 | expect(before).toContain("<dt>Refused today</dt><dd>0</dd>"); | |
| 149 | const now = new Date().toISOString(); | |
| 150 | app.db.query("INSERT INTO stripe_event (id, type, at) VALUES ('evt_page', 'invoice.paid', ?1)").run(now); | |
| 151 | app.db.query("INSERT INTO stripe_hook_refused (day, kind, n) VALUES (?1, 'bad', 1)").run(now.slice(0, 10)); | |
| 152 | const after = await (await send("GET", "/settings/billing", admin)).text(); | |
| 153 | expect(after).toContain("invoice.paid"); | |
| 154 | expect(after).toContain(now.slice(0, 16).replace("T", " ")); | |
| 155 | expect(after).toContain("<dt>Refused today</dt><dd>1</dd>"); | |
| 156 | expect(after).not.toContain("No event yet"); | |
| 157 | expect(after).toContain("<dt>Refused today</dt>"); | |
| 158 | }); | |
| 159 | ||
| 160 | test("a non-admin gets 404 and no event data on the Billing page", async () => { | |
| 161 | const res = await send("GET", "/settings/billing", bob); | |
| 162 | expect(res.status).toBe(404); | |
| 163 | const text = await res.text(); | |
| 164 | expect(text).not.toContain("invoice.paid"); | |
| 165 | expect(text).not.toContain("Refused today"); | |
| 166 | }); | |
| 144 | 167 | }); |
new above
offline
This read has no terminal screen. Both doors: expose
hookSummaryin asrc/tui/screen gated like the page (admin only), with a test, and document it indocs/features/<name>.md.