Public: anyone can read this; the crew can change it.
During grace, a forge's admins see one line with a pay link in its settings, and the operator sees grace on /forges #11487
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11394-during-grace-a-forge-s-admins-see-one-li → main 4 commits fork 3 threads · 3 unresolved
10 files
changed +181
−22
docs/features/plan-row.md +3 −1
| @@ -1,7 +1,9 @@ | ||
| 1 | 1 | # The Plan row in a forge's settings (#11141) |
| 2 | 2 | |
| 3 | A forge's admins see one `status-rows` `dl` row (`Plan`) at the top of `/settings`: the plan's name and size, the crew credit used this month, and the status (`Active`, or `Past due, sleeps <date>`). With a billing portal set and a Stripe payer, **Manage billing** links to the provider's customer-portal sign-in (the buyer signs in with the email they paid with); **Change size** links to the host's `/pricing`. A forge with no notice (never told, or a host that is not its parent) shows no row. | |
| 3 | A forge's admins see one `status-rows` `dl` row (`Plan`) at the top of `/settings`: the plan's name and size, the crew credit used this month, and the status (`Active`, or in grace `Payment due — paused on <day> · Pay`, #11394). With a billing portal set and a Stripe payer, **Manage billing** links to the provider's customer-portal sign-in (the buyer signs in with the email they paid with); **Change size** links to the host's `/pricing`. A forge with no notice (never told, or a host that is not its parent) shows no row. | |
| 4 | 4 | |
| 5 | - **Grace (#11394):** while a payment is late the row says `Payment due — paused on <day>` (the day grace ends, UTC) followed by one **Pay** link: the provider's portal when one is set, else the host's `/pricing`. Pay takes the place of Manage billing, and when Pay already goes to `/pricing`, Change size is dropped so the row never offers two links to one place. Home and board carry no notice. | |
| 6 | - **Operator:** on the host's `/forges` (operator only) a forge in grace shows `grace until <YYYY-MM-DD>` in its row (`graceWords` in `src/spawn/clients.ts`); the terminal's forges screen says the same. | |
| 5 | 7 | - **Host:** Settings → Billing → *Billing portal* sets `billing.portal_url` (https only, empty clears). Changing it tells every forge sent from here. |
| 6 | 8 | - **How a forge learns its plan:** the host writes a notice (`src/billing/notice.ts`) into that one forge's own `plan.json` (never `parent.json`) when the forge is made, a gift is redeemed, a plan is granted, or Stripe changes the subscription. The forge reads only its own file and calls nothing on the host, so it cannot read another forge's plan; the notice has no forge name, customer or subscription id. |
| 7 | 9 | - **Terminal:** the palette entry *Plan* (`src/tui/screens/planrow.ts`, any admin) shows the same plan line (`planLine`) and, for an admin who is a person, sets the billing portal address with Enter through `changePortalUrl`, as the page does. |
src/billing/notice.ts +5 −1
| @@ -15,6 +15,7 @@ import type { App } from "../app.ts"; | ||
| 15 | 15 | import { childDirOf, listChildrenNow } from "../actions/spawn.ts"; |
| 16 | 16 | import { getPortalUrl, parseLink } from "../settings/billing.ts"; |
| 17 | 17 | import { getPlan } from "../settings/plans.ts"; |
| 18 | import { lapseFor } from "./lapse.ts"; | |
| 18 | 19 | import { entitlementOf, getStored } from "./store.ts"; |
| 19 | 20 | import type { EntitlementStatus } from "./types.ts"; |
| 20 | 21 | |
| @@ -81,6 +82,9 @@ export function tellPlan(app: App, forge: string): boolean { | ||
| 81 | 82 | const e = entitlementOf(app.db, forge, child.size); |
| 82 | 83 | const plan = getPlan(app.db, e.plan); |
| 83 | 84 | const portal = getPortalUrl(app.db); |
| 85 | // In grace the forge is told the day it pauses (the end of grace), not the provider's period end. | |
| 86 | const lapse = e.status === "grace" ? lapseFor(app.db, forge) : undefined; | |
| 87 | const periodEnd = lapse?.state === "grace" ? Math.floor(lapse.graceUntil / 1000) : e.periodEnd; | |
| 84 | 88 | const paysAtProvider = getStored(app.db, forge)?.provider === "stripe"; |
| 85 | 89 | return keepNotice(childDirOf(app, child.bodyId), { |
| 86 | 90 | name: plan?.name ?? e.plan, |
| @@ -88,7 +92,7 @@ export function tellPlan(app: App, forge: string): boolean { | ||
| 88 | 92 | priceUsd: plan?.priceUsd ?? 0, |
| 89 | 93 | creditUsd: plan?.creditUsd ?? 0, |
| 90 | 94 | status: e.status, |
| 91 | ...(e.periodEnd === undefined ? {} : { periodEnd: e.periodEnd }), | |
| 95 | ...(periodEnd === undefined ? {} : { periodEnd }), | |
| 92 | 96 | ...(paysAtProvider && portal !== "" ? { portalUrl: portal } : {}), |
| 93 | 97 | }); |
| 94 | 98 | } catch { |
src/spawn/clients.ts +18
| @@ -22,6 +22,7 @@ import { lastHeard, type Beat } from "./heartbeat.ts"; | ||
| 22 | 22 | import { reachOf, reachWords, type ChildReach } from "./reach.ts"; |
| 23 | 23 | import type { Size } from "./sizes.ts"; |
| 24 | 24 | import { firstScreenLine } from "../core/next-steps.ts"; |
| 25 | import { lapseFor } from "../billing/lapse.ts"; | |
| 25 | 26 | import { forgeUse, type ForgeUse } from "./usage.ts"; |
| 26 | 27 | |
| 27 | 28 | /** What a spawned forge says about itself to anyone: nothing more. */ |
| @@ -70,6 +71,8 @@ export interface ClientRow { | ||
| 70 | 71 | readonly disk: ChildDisk; |
| 71 | 72 | /** When its last heartbeat reached this forge, ISO; null when none has. */ |
| 72 | 73 | readonly lastHeartbeatAt: string | null; |
| 74 | /** Epoch ms its grace for a late payment ends; absent when it is not in grace. */ | |
| 75 | readonly graceUntil?: number; | |
| 73 | 76 | /** The first step its person has not taken, read from the child's heartbeat. */ |
| 74 | 77 | readonly nextStep: NextStep; |
| 75 | 78 | } |
| @@ -245,6 +248,7 @@ export async function clientRows(app: App): Promise<ClientRow[]> { | ||
| 245 | 248 | spendTodayUsd: beats[i]?.beat.spendTodayUsd ?? null, |
| 246 | 249 | ceilingUsd: beats[i]?.beat.ceilingUsd ?? null, |
| 247 | 250 | ...creditView(app, c.name), |
| 251 | ...graceView(app, c.name, now), | |
| 248 | 252 | size: c.size, |
| 249 | 253 | use: forgeUse(c, childDirOf(app, c.bodyId)), |
| 250 | 254 | disk: liveChildDisk(app.config.dataDir, c.bodyId, mountinfo), |
| @@ -395,3 +399,17 @@ export function spendWords(r: ClientRow): { words: string; warn: boolean } | nul | ||
| 395 | 399 | ? { words: `${base} · ceiling reached`, warn: true } |
| 396 | 400 | : { words: base, warn: share >= 0.8 }; |
| 397 | 401 | } |
| 402 | ||
| 403 | /** "grace until 2026-11-18" for a forge whose payment is late, as /forges and the terminal say it; null otherwise. */ | |
| 404 | export function graceWords(r: Pick<ClientRow, "graceUntil">): string | null { | |
| 405 | return r.graceUntil === undefined ? null : `grace until ${new Date(r.graceUntil).toISOString().slice(0, 10)}`; | |
| 406 | } | |
| 407 | ||
| 408 | function graceView(app: App, forge: string, now: number): { graceUntil?: number } { | |
| 409 | try { | |
| 410 | const l = lapseFor(app.db, forge, now); | |
| 411 | return l.state === "grace" ? { graceUntil: l.graceUntil } : {}; | |
| 412 | } catch { | |
| 413 | return {}; | |
| 414 | } | |
| 415 | } |
src/tui/screens/forges.ts +2 −2
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Ctx, Screen } from "../app/app.ts"; |
| 8 | 8 | import { binding } from "../app/keys.ts"; |
| 9 | import { ATTENTION_WORDS, firstScreenOf, CLAIM_WORDS, claimState, creditWords, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts"; | |
| 9 | import { ATTENTION_WORDS, firstScreenOf, CLAIM_WORDS, claimState, creditWords, graceWords, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts"; | |
| 10 | 10 | import { useLine } from "../../spawn/usage.ts"; |
| 11 | 11 | import { timeAgo } from "../../web/ui.ts"; |
| 12 | 12 | import { card } from "../ui/widgets.ts"; |
| @@ -26,7 +26,7 @@ export function forgeLines(rows: readonly ClientRow[]): [string, string][] { | ||
| 26 | 26 | if (rows.length === 0) return [["", "You have not sent a forge yet"]]; |
| 27 | 27 | return rows.flatMap((r): [string, string][] => [[ |
| 28 | 28 | r.name, |
| 29 | [r.made ? (r.awake === true ? "awake" : "asleep") : null, r.attention === null ? null : ATTENTION_WORDS[r.attention], r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], linkWords(r.link), runningWords(r), r.stage?.words ?? null, r.nextStep.words, spendWords(r)?.words ?? null, creditWords(r), useLine(r.size, r.use), r.made ? diskWords(r.disk) : null, r.lastHeartbeatAt === null ? "no heartbeat yet" : `heartbeat ${timeAgo(r.lastHeartbeatAt)}`] | |
| 29 | [r.made ? (r.awake === true ? "awake" : "asleep") : null, r.attention === null ? null : ATTENTION_WORDS[r.attention], r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], linkWords(r.link), runningWords(r), r.stage?.words ?? null, r.nextStep.words, spendWords(r)?.words ?? null, creditWords(r), graceWords(r), useLine(r.size, r.use), r.made ? diskWords(r.disk) : null, r.lastHeartbeatAt === null ? "no heartbeat yet" : `heartbeat ${timeAgo(r.lastHeartbeatAt)}`] | |
| 30 | 30 | .filter((m): m is string => m !== null) |
| 31 | 31 | .join(" · "), |
| 32 | 32 | ], ...firstScreenLines(r), ...addressLines(r)]); |
src/web/pages/forges.ts +2 −1
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Ctx } from "../../http/context.ts"; |
| 10 | import { ATTENTION_WORDS, firstScreenOf, CLAIM_WORDS, claimState, clientRows, creditWords, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts"; | |
| 10 | import { ATTENTION_WORDS, firstScreenOf, CLAIM_WORDS, claimState, clientRows, creditWords, graceWords, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts"; | |
| 11 | 11 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 12 | 12 | import { notFoundPage, pageHead } from "../chrome.ts"; |
| 13 | 13 | import { html, type Html } from "../html.ts"; |
| @@ -56,6 +56,7 @@ export function forgeRow(r: ClientRow, now: number): Html { | ||
| 56 | 56 | runningNote(r), |
| 57 | 57 | spendNote(r), |
| 58 | 58 | creditWords(r) === null ? null : html`<span data-credit>${creditWords(r)}</span>`, |
| 59 | graceWords(r) === null ? null : html`<span data-grace>${graceWords(r)}</span>`, | |
| 59 | 60 | r.stage === null |
| 60 | 61 | ? null |
| 61 | 62 | : html`<span data-stage="${r.stage.key}"${r.stage.resend ? html` data-resend` : null}>${r.stage.words}${r.stage.at === null ? null : html` ${time(r.stage.at)}`}</span>`, |
src/web/plan_row.ts +26 −12
| @@ -1,11 +1,12 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The Plan row in a forge's settings (#11141), one `dl` row for its admins: the |
| 3 | * plan's name and size, the crew credit used this month, and the status (past | |
| 4 | * due says the date it sleeps). Everything is what the host told this forge | |
| 5 | * (`billing/notice.ts`) and its own funding file; nothing is asked of the | |
| 6 | * host. "Manage billing" is the provider's portal sign-in, shown only when | |
| 7 | * the host set one and this forge pays through it; "Change size" is the | |
| 8 | * host's /pricing. | |
| 3 | * plan's name and size, the crew credit used this month, and the status (in | |
| 4 | * grace, "Payment due — paused on <day> · Pay"). Everything is what the host | |
| 5 | * told this forge (`billing/notice.ts`) and its own funding file; nothing is | |
| 6 | * asked of the host. "Manage billing" is the provider's portal sign-in, shown | |
| 7 | * only when the host set one and this forge pays through it, and not in grace, | |
| 8 | * where Pay takes its place; "Change size" is the host's /pricing, dropped | |
| 9 | * when Pay already points there. | |
| 9 | 10 | */ |
| 10 | 11 | |
| 11 | 12 | import { noticeOf, type PlanNotice } from "../billing/notice.ts"; |
| @@ -15,11 +16,16 @@ import { sizeWords } from "../settings/billing.ts"; | ||
| 15 | 16 | import { isSize } from "../spawn/sizes.ts"; |
| 16 | 17 | import { html, type Html } from "./html.ts"; |
| 17 | 18 | |
| 18 | const day = (secs: number): string => new Date(secs * 1000).toISOString().slice(0, 10); | |
| 19 | const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; | |
| 20 | /** "18 Nov", in UTC. */ | |
| 21 | export const dayWords = (secs: number): string => { | |
| 22 | const d = new Date(secs * 1000); | |
| 23 | return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`; | |
| 24 | }; | |
| 19 | 25 | |
| 20 | /** "Active", or "Past due, sleeps 2026-12-01". */ | |
| 26 | /** "Active", or "Payment due — paused on 18 Nov". */ | |
| 21 | 27 | export function statusWords(n: Pick<PlanNotice, "status" | "periodEnd">): string { |
| 22 | if (n.status === "grace") return n.periodEnd === undefined ? "Past due" : `Past due, sleeps ${day(n.periodEnd)}`; | |
| 28 | if (n.status === "grace") return n.periodEnd === undefined ? "Payment due" : `Payment due — paused on ${dayWords(n.periodEnd)}`; | |
| 23 | 29 | return n.status === "none" ? "No active plan" : "Active"; |
| 24 | 30 | } |
| 25 | 31 | |
| @@ -38,8 +44,16 @@ export function planRow(dataDir: string, parentOrigin: string | undefined, admin | ||
| 38 | 44 | if (!admin) return null; |
| 39 | 45 | const line = planLine(dataDir); |
| 40 | 46 | if (line === undefined) return null; |
| 41 | const portal = noticeOf(dataDir)?.portalUrl; | |
| 47 | const n = noticeOf(dataDir); | |
| 48 | const portal = n?.portalUrl; | |
| 49 | // In grace the one link that matters is Pay: the provider's portal, else the host's pricing page. | |
| 50 | const pay = n?.status === "grace" ? (portal ?? (parentOrigin === undefined ? undefined : `${parentOrigin}/pricing`)) : undefined; | |
| 42 | 51 | return html`<dl class="status-rows"><div class="status-row"><dt>Plan</dt><dd>${line}${ |
| 43 | portal === undefined ? "" : html` <a href="${portal}" rel="noopener noreferrer" target="_blank">Manage billing</a>` | |
| 44 | }${parentOrigin === undefined ? "" : html` <a href="${parentOrigin}/pricing">Change size</a>`}</dd></div></dl>`; | |
| 52 | pay === undefined ? "" : html` · <a href="${pay}" rel="noopener noreferrer">Pay</a>` | |
| 53 | }${ | |
| 54 | portal === undefined || pay !== undefined ? "" : html` <a href="${portal}" rel="noopener noreferrer" target="_blank">Manage billing</a>` | |
| 55 | }${ | |
outdated
Principle 8/7: in grace with no portal, Pay and Change size are the same href. Show one. |
||
| 56 | // One link per place: when Pay already goes to the host's pricing page, Change size would repeat it. | |
| 57 | parentOrigin === undefined || pay === `${parentOrigin}/pricing` ? "" : html` <a href="${parentOrigin}/pricing">Change size</a>` | |
| 58 | }</dd></div></dl>`; | |
| 45 | 59 | } |
test/grace-calm.test.ts added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | /** | |
| 2 | * Grace is one line where it belongs (#11394): the forge's own /settings Plan | |
| 3 | * line says "Payment due — paused on <day> · Pay", the operator's /forges row | |
| 4 | * says "grace until <date>", and the home and the board carry no notice. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { childDirOf } from "../src/actions/spawn.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { keepNotice, noticeOf, tellPlan } from "../src/billing/notice.ts"; | |
| 13 | import { putEntitlement } from "../src/billing/store.ts"; | |
| 14 | import { createSession } from "../src/core/sessions.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { clientRows, graceWords, type ClientRow } from "../src/spawn/clients.ts"; | |
| 17 | import { forgeLines } from "../src/tui/screens/forges.ts"; | |
| 18 | import { planLine } from "../src/web/plan_row.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | const ELEMENT = /<[a-z][^<>]*?(?:\brole="alert"|\bclass="[^"]*\b(?:banner|notice|notice-danger|notice-warn|alert)\b[^"]*")[^<>]*>/gi; | |
| 25 | const allowed = (tag: string): boolean => /\bclass="[^"]*\btoast\b/.test(tag) && (/\brole="status"/.test(tag) || /\bdata-tone="danger"/.test(tag)); | |
| 26 | const standing = (body: string): string[] => [...body.matchAll(ELEMENT)].map((m) => m[0]).filter((t) => !allowed(t)); | |
| 27 | ||
| 28 | let dir = ""; | |
| 29 | let app: App; | |
| 30 | let servers: Servers; | |
| 31 | let cookie = ""; | |
| 32 | const PORTAL = "https://billing.stripe.com/p/login/grace"; | |
| 33 | ||
| 34 | beforeAll(async () => { | |
| 35 | dir = await mkdtemp(join(tmpdir(), "forge-grace-")); | |
| 36 | app = createApp({ dataDir: dir, port: 0 }); | |
| 37 | servers = await serve(app); | |
| 38 | const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 39 | if (!admin.ok) throw new Error(admin.error); | |
| 40 | cookie = `forge_session=${createSession(app.db, admin.value.id).token}`; | |
| 41 | // This forge is itself told it is in grace (its own plan.json), and as a host it holds a child in grace. | |
| 42 | await writeFile(join(dir, "parent.json"), JSON.stringify({ upstream: "https://host.example" })); | |
| 43 | keepNotice(dir, { name: "Medium", size: "medium", priceUsd: 49, creditUsd: 10, status: "grace", periodEnd: Date.UTC(2026, 10, 18) / 1000, portalUrl: PORTAL }); | |
| 44 | await mkdir(join(dir, "spawn"), { recursive: true }); | |
| 45 | await writeFile(join(dir, "spawn", "alpha.json"), JSON.stringify({ name: "alpha", bodyId: "body-alpha", done: true, size: "small", org: "alpha", apps: [] })); | |
| 46 | await mkdir(childDirOf(app, "body-alpha"), { recursive: true }); | |
| 47 | await writeFile(join(childDirOf(app, "body-alpha"), "parent.json"), JSON.stringify({ upstream: "https://host.example" })); | |
| 48 | putEntitlement(app.db, { forge: "alpha", plan: "medium", status: "grace", periodEnd: 1_796_000_000, provider: "stripe", customerId: "cus_x", subscriptionId: "sub_x", source: "stripe" }, "small", ""); | |
| 49 | }); | |
| 50 | ||
| 51 | afterAll(async () => { | |
| 52 | servers.stop(); | |
| 53 | app.close(); | |
| 54 | await rm(dir, { recursive: true, force: true }); | |
| 55 | }); | |
| 56 | ||
| 57 | const get = async (path: string): Promise<string> => { | |
| 58 | const res = await fetch(`${servers.url}${path}`, { headers: { cookie }, redirect: "follow" }); | |
| 59 | expect(res.status).toBe(200); | |
| 60 | return res.text(); | |
| 61 | }; | |
| 62 | ||
| 63 | test("the plan line says when it pauses, and settings carries it with a Pay link", async () => { | |
| 64 | expect(planLine(dir)).toContain("Payment due — paused on 18 Nov"); | |
| 65 | const body = await get("/settings"); | |
| 66 | expect(body).toContain("Payment due — paused on 18 Nov"); | |
| 67 | expect(body).toMatch(new RegExp(`· <a href="${PORTAL}"[^>]*>Pay</a>`)); | |
| 68 | }); | |
| 69 | ||
| 70 | test("a told forge learns the day grace ends, not the provider's period end", () => { | |
| 71 | expect(tellPlan(app, "alpha")).toBe(true); | |
| 72 | const told = noticeOf(childDirOf(app, "body-alpha"))!; | |
| 73 | expect(told.status).toBe("grace"); | |
| 74 | expect(told.periodEnd).toBeGreaterThan(Date.now() / 1000); | |
| 75 | expect(told.periodEnd).not.toBe(1_796_000_000); | |
| 76 | }); | |
| 77 | ||
| 78 | test("the operator's /forges row says grace until the date", async () => { | |
| 79 | const body = await get("/forges"); | |
| 80 | expect(body).toMatch(/<span data-grace>grace until \d{4}-\d{2}-\d{2}<\/span>/); | |
| 81 | expect(graceWords({})).toBeNull(); | |
| 82 | expect(graceWords({ graceUntil: Date.UTC(2026, 10, 18) })).toBe("grace until 2026-11-18"); | |
| 83 | }); | |
| 84 | ||
| 85 | test("in grace, the home and the board carry no notice and none of the words", async () => { | |
| 86 | for (const path of ["/", "/board"]) { | |
| 87 | const body = await get(path); | |
| 88 | expect({ path, found: standing(body) }).toEqual({ path, found: [] }); | |
| 89 | expect(body).not.toContain("Payment due"); | |
| 90 | expect(body).not.toContain("grace until"); | |
| 91 | } | |
| 92 | }); | |
| 93 | ||
| 94 | test("the terminal's forges screen says grace until the date for a forge in grace, and only then", async () => { | |
| 95 | const rows = await clientRows(app); | |
| 96 | const alpha = rows.find((r) => r.graceUntil !== undefined); | |
| 97 | expect(alpha).toBeDefined(); | |
| 98 | const lines = forgeLines([alpha!]).map((l) => l[1]).join("\n"); | |
| 99 | expect(lines).toContain(`grace until ${new Date(alpha!.graceUntil!).toISOString().slice(0, 10)}`); | |
| 100 | expect(forgeLines([{ ...alpha!, graceUntil: undefined } as unknown as ClientRow]).map((l) => l[1]).join("\n")).not.toContain("grace until"); | |
| 101 | }); |
test/plan-notice.test.ts +21 −3
| @@ -85,10 +85,9 @@ describe("plan notice", () => { | ||
| 85 | 85 | const row = planRow(a, "https://host.example", true)!.toString(); |
| 86 | 86 | expect(row.split("\n").length).toBe(1); |
| 87 | 87 | expect(row).toContain("<dt>Plan</dt>"); |
| 88 | expect(row).not.toContain(" · "); | |
| 88 | expect(row).toContain(" · <a href=\"" + PORTAL + "\""); | |
| 89 | 89 | expect(row).toContain("Medium"); |
| 90 | expect(row).toContain("Past due, sleeps 2026-"); | |
| 91 | expect(row).toContain(`href="${PORTAL}"`); | |
| 90 | expect(row).toContain("Payment due — paused on "); | |
| 92 | 91 | expect(row).toContain('href="https://host.example/pricing"'); |
outdated
Whitespace-only line left where an assertion was deleted. Remove it. |
||
| 93 | 92 | expect(planRow(a, "https://host.example", false)).toBeNull(); |
| 94 | 93 | // No portal URL for a forge that does not pay through Stripe: the plan, no link. |
| @@ -100,6 +99,25 @@ describe("plan notice", () => { | ||
| 100 | 99 | expect(planRow(join(dir, "nowhere"), undefined, true)).toBeNull(); |
| 101 | 100 | }); |
| 102 | 101 | |
| 102 | test("in grace with no portal, Pay goes to the host's pricing and Change size is not repeated", async () => { | |
| 103 | const cd = await mkdtemp(join(tmpdir(), "forge-grace-row-")); | |
| 104 | try { | |
| 105 | await writeFile(join(cd, "plan.json"), JSON.stringify({ name: "Plan", size: "small", status: "grace", periodEnd: Date.UTC(2026, 10, 18) / 1000 })); | |
| 106 | const row = planRow(cd, "https://host.example", true)!.toString(); | |
| 107 | expect(row).toContain('Payment due — paused on 18 Nov. · <a href="https://host.example/pricing"'); | |
| 108 | expect(row.match(/href="https:\/\/host\.example\/pricing"/g)?.length).toBe(1); | |
| 109 | expect(row).not.toContain("Change size"); | |
| 110 | // With a portal, Pay goes there and Change size still offers the host's pricing. | |
| 111 | await writeFile(join(cd, "plan.json"), JSON.stringify({ name: "Plan", size: "small", status: "grace", portalUrl: PORTAL })); | |
| 112 | const withPortal = planRow(cd, "https://host.example", true)!.toString(); | |
| 113 | expect(withPortal).toContain(`· <a href="${PORTAL}"`); | |
| 114 | expect(withPortal).toContain("Change size"); | |
| 115 | expect(withPortal).not.toContain("Manage billing"); | |
| 116 | } finally { | |
| 117 | await rm(cd, { recursive: true, force: true }); | |
| 118 | } | |
| 119 | }); | |
| 120 | ||
| 103 | 121 | test("a hostile notice is refused on read", async () => { |
| 104 | 122 | const cd = childDirOf(app, "body-beta"); |
| 105 | 123 | await writeFile(join(cd, "plan.json"), JSON.stringify({ name: "x", size: "small", status: "active", portalUrl: "javascript:alert(1)" })); |
test/plan-wiring.test.ts +2 −1
| @@ -89,7 +89,8 @@ test("a completed checkout and a Stripe sync tell the forge, with the portal lin | ||
| 89 | 89 | sealKey(app.db, dir, "sk_test_abcdefgh12345678"); |
| 90 | 90 | setStripeFetch(app, (async () => new Response(JSON.stringify({ id: "sub_w", status: "past_due", customer: "cus_w", metadata: { forge: "g3", plan: "medium" }, items: { data: [{ current_period_end: 1900000000 }] } }), { status: 200 })) as unknown as typeof fetch); |
| 91 | 91 | await syncSubscription(app, "sub_w"); |
| 92 | expect(noticeOf(cd)).toMatchObject({ status: "grace", periodEnd: 1900000000 }); | |
| 92 | expect(noticeOf(cd)).toMatchObject({ status: "grace" }); | |
| 93 | expect(noticeOf(cd)!.periodEnd).toBeGreaterThan(Date.now() / 1000); | |
| 93 | 94 | }); |
| 94 | 95 | |
| 95 | 96 | test("a new portal address tells every forge; clearing it removes the link", async () => { |
test/tui-plan-row.test.ts +1 −1
| @@ -63,7 +63,7 @@ test("an admin reads the plan line and sets the portal; a bad address keeps noth | ||
| 63 | 63 | const h = boot("alice"); |
| 64 | 64 | await h.open(); |
| 65 | 65 | expect(h.text()).toContain("Medium"); |
| 66 | expect(h.text()).toContain("Past due, sleeps 2030-"); | |
| 66 | expect(h.text()).toContain("Payment due — paused on "); | |
| 67 | 67 | await h.press("enter"); |
| 68 | 68 | for (const ch of "http://insecure.test") await h.press(ch); |
| 69 | 69 | await h.press("enter"); |
new above
offline
The terminal says graceWords, but no test shows a forge in grace producing
grace until <date>in forgeLines. Both doors need a test that would fail without this. Add one to the TUI forges test.