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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11248-the-admin-billing-page-shows-the-last-st → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/11248-the-admin-billing-page-shows-the-last-st was merged into main as d50cd44.

Review

Sign in to review this change.
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";
1212 import type { User } from "../core/users.ts";
1313 import { err, ok, type Result } from "../kernel/result.ts";
1414 import { connectionOf, forget, openKey, sealKey, sealWebhook, type Connection } from "../billing/connection.ts";
15 import { hookSummary } from "../http/stripe_hook.ts";
1516 import { isStripeKey, stripeClient, type StripeError } from "../billing/stripe.ts";
1617 import { publicOrigin } from "../core/public_origin.ts";
1718 import { holdValues } from "../kernel/log.ts";
@@ -41,6 +42,8 @@ export interface BillingView {
4142 readonly forges: readonly (Entitlement & { size: string })[];
4243 /** The Stripe connection (mode, whether events are signed), never the key. */
4344 readonly stripe: Connection | undefined;
45 /** The newest Stripe event kept and the signatures refused on this UTC day. */
46 readonly events: ReturnType<typeof hookSummary>;
4447 }
4548
4649 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
5154 return ok({
5255 forges: listChildrenNow(app).map((c) => ({ ...entitlementOf(app.db, c.name, c.size), size: c.size })),
5356 stripe: connectionOf(app.db),
57 events: hookSummary(app),
5458 });
5559 }
5660
src/http/stripe_hook.ts +8
@@ -57,6 +57,14 @@ export function refusedCount(app: App, kind: "bad" | "stale"): number {
5757 );
5858 }
5959
outdated

This read has no terminal screen. Both doors: expose hookSummary in a src/tui/ screen gated like the page (admin only), with a test, and document it in docs/features/<name>.md.

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
6068 function countRefused(app: App, kind: "bad" | "stale", now: Date): void {
6169 const row = app.db
6270 .query<{ n: number }, [string, string]>(
src/tui/screens/billing.ts +5 −2
@@ -41,11 +41,14 @@ export function billingScreen(f: Session, ctx: Ctx): Screen {
4141 state.clamp(rows.length);
4242 const sc = readBilling(f.app, f.user);
4343 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}`;
4446 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 });
4648 return;
4749 }
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);
4952 const line = note === "" ? `${stripe} (c connect, w secret, x disconnect)` : note;
5053 s.text(head!.x, head!.y, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
5154 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> = {
160160 );
161161 const rendering = performance.now();
162162 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 dl row, not a sentence. This is one <p class="small"> with an inline <span class="mono"> and a · joining two facts. Render two status-rows (Last event, Refused today (UTC)) in the same status-rows list the Stripe block uses; the no-event state is the row's value. Principle 8: plain words, so drop the inline parenthetical.

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>`;
163168 const stripe = html`<section class="settings-block">
164169 <h2>Provider</h2>
170 ${hookRows}
165171 ${s
166172 ? html`<dl class="status-rows">
167173 <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
158158 expect(bobs.tui.screen.toText()).not.toContain("Remove the webhook");
159159 bobs.tui.stop?.();
160160 });
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", () => {
141141 expect(text).toContain('data-forge="kid"');
142142 expect((await send("GET", "/settings/billing", bob)).status).toBe(404);
143143 });
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 });
144167 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs