Public: anyone can read this; the crew can change it.
A forge's owner sees their plan in their forge's settings, with a link to manage billing at the provider #11356
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11141-a-forge-s-owner-sees-their-plan-in-their → main 8 commits fork 3 threads · 3 unresolved
20 files
changed +629
−7
docs/features/plan-row.md added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | # The Plan row in a forge's settings (#11141) | |
| 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. | |
| 4 | ||
| 5 | - **Host:** Settings → Billing → *Billing portal* sets `billing.portal_url` (https only, empty clears). Changing it tells every forge sent from here. | |
| 6 | - **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 | - **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/actions/billing.ts +2
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { App } from "../app.ts"; |
| 10 | import { tellPlan } from "../billing/notice.ts"; | |
| 10 | 11 | import { changesOf, entitlementOf, putEntitlement, type EntitlementChange } from "../billing/store.ts"; |
| 11 | 12 | import type { Entitlement } from "../billing/types.ts"; |
| 12 | 13 | import type { User } from "../core/users.ts"; |
| @@ -87,6 +88,7 @@ export function grantPlan(app: App, actor: User | undefined, forge: unknown, pla | ||
| 87 | 88 | const { from, to } = planned.value; |
| 88 | 89 | const size = listChildrenNow(app).find((c) => c.name === to.forge)?.size ?? from.plan; |
| 89 | 90 | const entitlement = putEntitlement(app.db, { forge: to.forge, plan: to.plan, status: "active", source: "granted", provider: "none" }, size, who.id); |
| 91 | tellPlan(app, to.forge); | |
| 90 | 92 | app.bus.append({ kind: "billing.plan", actorId: who.id, payload: { forge: to.forge, from: from.plan, to: to.plan, source: "granted" } }); |
| 91 | 93 | return ok({ entitlement, changes: changesOf(app.db, to.forge) }); |
| 92 | 94 | } |
src/actions/codes.ts +2
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import type { App } from "../app.ts"; |
| 12 | import { tellPlan } from "../billing/notice.ts"; | |
| 12 | 13 | import { |
| 13 | 14 | CODE_WORDS, MAX_NOTE, MAX_USES, insertCode, listCodes, lookupCode, noteRedeemed, |
| 14 | 15 | releaseCode, revokeCode, takeTry, type Code, type CodeRefusal, type Listed, |
| @@ -112,5 +113,6 @@ export function settleGift(app: App, code: Code, forge: string): void { | ||
| 112 | 113 | const size = getPlan(app.db, code.plan)?.size ?? "small"; |
| 113 | 114 | noteRedeemed(app.db, code.id, forge); |
| 114 | 115 | putEntitlement(app.db, { forge, plan: code.plan, status: "active", source: `code:${code.id}`, provider: "none" }, size, ""); |
| 116 | tellPlan(app, forge); | |
| 115 | 117 | app.bus.append({ kind: "billing.code.redeem", payload: { id: code.id, forge, plan: code.plan } }); |
| 116 | 118 | } |
src/actions/signup.ts +11 −1
| @@ -14,7 +14,8 @@ import { | ||
| 14 | 14 | } from "../settings/signup.ts"; |
| 15 | 15 | import { getAgentStart, parseAgentStart, setAgentStart, type AgentStartMode } from "../settings/agent_start.ts"; |
| 16 | 16 | import { forgeSource, parseForgeSource, setForgeSource, storedForgeSource } from "../settings/forge_source.ts"; |
| 17 | import { getBillingLinks, parseBillingLinks, setBillingLinks, type BillingLinks } from "../settings/billing.ts"; | |
| 17 | import { tellAll } from "../billing/notice.ts"; | |
| 18 | import { getBillingLinks, parseBillingLinks, setBillingLinks, setPortalUrl, type BillingLinks } from "../settings/billing.ts"; | |
| 18 | 19 | import type { User } from "../core/users.ts"; |
| 19 | 20 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 20 | 21 | |
| @@ -110,3 +111,12 @@ export function changeBillingLinks( | ||
| 110 | 111 | setBillingLinks(app.db, links.value, who.value.id); |
| 111 | 112 | return ok(links.value); |
| 112 | 113 | } |
| 114 | ||
| 115 | /** The billing portal address (`settings/billing.ts`) and every forge told it, for a forge admin who is a person. */ | |
| 116 | export function changePortalUrl(app: App, actor: User | undefined, raw: unknown): Result<string, "not_found" | "invalid_portal_url"> { | |
| 117 | const who = admin(actor); | |
| 118 | if (!who.ok) return who; | |
| 119 | const set = setPortalUrl(app.db, raw, who.value.id); | |
| 120 | if (set.ok) tellAll(app); | |
| 121 | return set; | |
| 122 | } |
src/actions/spawn.ts +2
| @@ -16,6 +16,7 @@ import { readdirSync, readFileSync } from "node:fs"; | ||
| 16 | 16 | import { mkdir, readdir, rename } from "node:fs/promises"; |
| 17 | 17 | import { join } from "node:path"; |
| 18 | 18 | import { createApp, type App } from "../app.ts"; |
| 19 | import { tellPlan } from "../billing/notice.ts"; | |
| 19 | 20 | import { |
| 20 | 21 | addCrew, |
| 21 | 22 | BRIEF_MAX, |
| @@ -336,6 +337,7 @@ async function make( | ||
| 336 | 337 | grantPlanCredit(app, plan, ledger.bodyId); |
| 337 | 338 | ledger.done = true; |
| 338 | 339 | await writeLedger(app, ledger); |
| 340 | tellPlan(app, plan.name); | |
| 339 | 341 | app.bus.append({ |
| 340 | 342 | kind: "forge.spawned", |
| 341 | 343 | actorId: owner?.id, |
src/billing/checkout_completed.ts +2
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import type { App } from "../app.ts"; |
| 11 | import { tellPlan } from "./notice.ts"; | |
| 11 | 12 | import { listChildrenNow } from "../actions/spawn.ts"; |
| 12 | 13 | import { parseSize } from "../spawn/sizes.ts"; |
| 13 | 14 | import { entitlementOf, putEntitlement } from "./store.ts"; |
| @@ -41,6 +42,7 @@ export function bindCheckout(app: App, event: unknown): boolean { | ||
| 41 | 42 | child.size, |
| 42 | 43 | "", |
| 43 | 44 | ); |
| 45 | tellPlan(app, forge); | |
| 44 | 46 | app.bus.append({ kind: "billing.plan", payload: { forge, to: plan, source: "stripe" } }); |
| 45 | 47 | return true; |
| 46 | 48 | } |
src/billing/notice.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** | |
| 2 | * What a forge is told of its own plan (#11141): its plan's name and size, | |
| 3 | * its status and period end, and the host's public billing-portal address | |
| 4 | * (`billing.portal_url`) when it pays through Stripe. The host writes it into | |
| 5 | * that one child's own `plan.json` (not `parent.json`, which holds nothing of the parent's) when the forge is made and | |
| 6 | * whenever its entitlement changes; the child only ever reads its own file. | |
| 7 | * No call from a forge reaches the host for it, so a forge cannot learn | |
| 8 | * another's plan, and the notice carries no forge name, customer or | |
| 9 | * subscription id. | |
| 10 | */ | |
| 11 | ||
| 12 | import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import type { App } from "../app.ts"; | |
| 15 | import { childDirOf, listChildrenNow } from "../actions/spawn.ts"; | |
| 16 | import { getPortalUrl, parseLink } from "../settings/billing.ts"; | |
| 17 | import { getPlan } from "../settings/plans.ts"; | |
| 18 | import { entitlementOf, getStored } from "./store.ts"; | |
| 19 | import type { EntitlementStatus } from "./types.ts"; | |
| 20 | ||
| 21 | export interface PlanNotice { | |
| 22 | readonly name: string; | |
| 23 | readonly size: string; | |
| 24 | readonly priceUsd: number; | |
| 25 | readonly creditUsd: number; | |
| 26 | readonly status: EntitlementStatus; | |
| 27 | /** Unix seconds the paid period ends (and a past-due forge sleeps), when known. */ | |
| 28 | readonly periodEnd?: number; | |
| 29 | /** The provider's customer-portal sign-in; absent without a provider or a URL. */ | |
| 30 | readonly portalUrl?: string; | |
| 31 | } | |
| 32 | ||
| 33 | const file = (dataDir: string): string => join(dataDir, "plan.json"); | |
| 34 | const parentFile = (dataDir: string): string => join(dataDir, "parent.json"); | |
| 35 | const STATUSES: readonly string[] = ["active", "grace", "none"]; | |
| 36 | ||
| 37 | function readDoc(dataDir: string): Record<string, unknown> | undefined { | |
| 38 | try { | |
| 39 | const d = JSON.parse(readFileSync(file(dataDir), "utf8")); | |
| 40 | return typeof d === "object" && d !== null && !Array.isArray(d) ? (d as Record<string, unknown>) : undefined; | |
| 41 | } catch { | |
| 42 | return undefined; | |
| 43 | } | |
| 44 | } | |
| 45 | ||
| 46 | const short = (v: unknown, max: number): string | undefined => (typeof v === "string" && v.length > 0 && v.length <= max && !/[\x00-\x1f]/.test(v) ? v : undefined); | |
| 47 | const money = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 100_000 ? v : 0); | |
| 48 | ||
| 49 | /** This forge's own notice, validated on read; undefined when it was told none. */ | |
| 50 | export function noticeOf(dataDir: string): PlanNotice | undefined { | |
| 51 | const r = readDoc(dataDir); | |
| 52 | if (r === undefined) return undefined; | |
outdated
Redundant: readDoc already returns a plain object or undefined. Drop the typeof check and the cast. |
||
| 53 | const name = short(r["name"], 60); | |
| 54 | const size = short(r["size"], 20); | |
| 55 | if (name === undefined || size === undefined || typeof r["status"] !== "string" || !STATUSES.includes(r["status"])) return undefined; | |
| 56 | const portal = typeof r["portalUrl"] === "string" ? parseLink(r["portalUrl"]) : undefined; | |
| 57 | return { | |
| 58 | name, | |
| 59 | size, | |
| 60 | priceUsd: money(r["priceUsd"]), | |
| 61 | creditUsd: money(r["creditUsd"]), | |
| 62 | status: r["status"] as EntitlementStatus, | |
| 63 | ...(typeof r["periodEnd"] === "number" && Number.isFinite(r["periodEnd"]) && r["periodEnd"] > 0 ? { periodEnd: r["periodEnd"] } : {}), | |
| 64 | ...(portal === undefined || portal === "" ? {} : { portalUrl: portal }), | |
| 65 | }; | |
| 66 | } | |
| 67 | ||
| 68 | /** Write the notice into the child's own `plan.json`; false when the child has no `parent.json` yet. */ | |
| 69 | export function keepNotice(dataDir: string, notice: PlanNotice): boolean { | |
| 70 | if (!existsSync(parentFile(dataDir))) return false; | |
| 71 | writeFileSync(`${file(dataDir)}.tmp`, JSON.stringify(notice), { mode: 0o600 }); | |
| 72 | renameSync(`${file(dataDir)}.tmp`, file(dataDir)); | |
| 73 | return true; | |
| 74 | } | |
| 75 | ||
| 76 | /** Tell one forge its plan as the host holds it now. Never throws: a forge that cannot be reached is told at the next change. */ | |
| 77 | export function tellPlan(app: App, forge: string): boolean { | |
| 78 | try { | |
| 79 | const child = listChildrenNow(app).find((c) => c.name === forge && c.done); | |
| 80 | if (child === undefined) return false; | |
| 81 | const e = entitlementOf(app.db, forge, child.size); | |
| 82 | const plan = getPlan(app.db, e.plan); | |
| 83 | const portal = getPortalUrl(app.db); | |
| 84 | const paysAtProvider = getStored(app.db, forge)?.provider === "stripe"; | |
| 85 | return keepNotice(childDirOf(app, child.bodyId), { | |
| 86 | name: plan?.name ?? e.plan, | |
| 87 | size: plan?.size ?? child.size, | |
| 88 | priceUsd: plan?.priceUsd ?? 0, | |
| 89 | creditUsd: plan?.creditUsd ?? 0, | |
| 90 | status: e.status, | |
| 91 | ...(e.periodEnd === undefined ? {} : { periodEnd: e.periodEnd }), | |
| 92 | ...(paysAtProvider && portal !== "" ? { portalUrl: portal } : {}), | |
| 93 | }); | |
| 94 | } catch { | |
| 95 | return false; | |
| 96 | } | |
| 97 | } | |
| 98 | ||
| 99 | /** Tell every forge sent from here, after the portal address changes. */ | |
| 100 | export function tellAll(app: App): void { | |
| 101 | for (const c of listChildrenNow(app)) tellPlan(app, c.name); | |
| 102 | } |
src/billing/sync.ts +2
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | |
| 11 | 11 | import type { App } from "../app.ts"; |
| 12 | 12 | import { listChildrenNow } from "../actions/spawn.ts"; |
| 13 | import { tellPlan } from "./notice.ts"; | |
| 13 | 14 | import { openKey } from "./connection.ts"; |
| 14 | 15 | import { getStored, putEntitlement } from "./store.ts"; |
| 15 | 16 | import { stripeClient, type StripeError } from "./stripe.ts"; |
| @@ -50,6 +51,7 @@ export async function syncSubscription(app: App, id: string | undefined): Promis | ||
| 50 | 51 | if (put === undefined) return; |
| 51 | 52 | const size = listChildrenNow(app).find((c) => c.name === put.forge)?.size ?? getStored(app.db, put.forge)?.plan ?? "small"; |
| 52 | 53 | putEntitlement(app.db, put, size, "stripe"); |
| 54 | tellPlan(app, put.forge); | |
| 53 | 55 | }); |
| 54 | 56 | return failed; |
| 55 | 57 | } |
src/http/settings_routes.ts +2 −1
| @@ -18,7 +18,7 @@ import { teamPage, teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts | ||
| 18 | 18 | import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts"; |
| 19 | 19 | import { flagsPage, flagsSet } from "../web/pages/flags.ts"; |
| 20 | 20 | import { appTemplatesPage, appTemplatesSet } from "../web/pages/app_templates.ts"; |
| 21 | import { billingPage, billingGrant, billingSetLinks, billingMakeCode, billingRevokeCode, stripeConnect, stripeDisconnect, stripeSecret, orderRetry } from "../web/pages/billing.ts"; | |
| 21 | import { billingPage, billingGrant, billingSetLinks, billingSetPortal, billingMakeCode, billingRevokeCode, stripeConnect, stripeDisconnect, stripeSecret, orderRetry } from "../web/pages/billing.ts"; | |
| 22 | 22 | import { economicsPage, economicsSet } from "../web/pages/economics.ts"; |
| 23 | 23 | import { trustedAdd, trustedPage, trustedRemove, trustedRevert, trustedStop } from "../web/pages/trusted.ts"; |
| 24 | 24 | import { usagePage } from "../web/pages/usage.ts"; |
| @@ -212,6 +212,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 212 | 212 | router.add("GET", "/settings/billing", billingPage); |
| 213 | 213 | router.add("POST", "/settings/billing/grant", billingGrant); |
| 214 | 214 | router.add("POST", "/settings/billing/links", billingSetLinks); |
| 215 | router.add("POST", "/settings/billing/portal", billingSetPortal); | |
| 215 | 216 | router.add("POST", "/settings/billing/stripe/connect", stripeConnect); |
| 216 | 217 | router.add("POST", "/settings/billing/stripe/secret", stripeSecret); |
| 217 | 218 | router.add("POST", "/settings/billing/stripe/disconnect", stripeDisconnect); |
src/settings/billing.ts +19 −1
| @@ -20,7 +20,7 @@ const MAX_URL = 300; | ||
| 20 | 20 | export const BILLING_WORDS = "billing links are an object of small, medium and large, each an https URL or empty"; |
| 21 | 21 | |
| 22 | 22 | /** One https URL with no credentials and no spaces, or the empty string. */ |
| 23 | function parseLink(raw: unknown): string | undefined { | |
| 23 | export function parseLink(raw: unknown): string | undefined { | |
| 24 | 24 | if (raw === null || raw === "") return ""; |
| 25 | 25 | if (typeof raw !== "string") return undefined; |
| 26 | 26 | const s = raw.trim(); |
| @@ -80,3 +80,21 @@ export function sizeWords(size: Size): string { | ||
| 80 | 80 | const gb = (mib: number): string => `${mib / 1024} GB`; |
| 81 | 81 | return `${s.cpu} ${s.cpu === 1 ? "CPU" : "CPUs"}, ${gb(s.memoryMiB)} memory, ${gb(s.diskMiB)} disk`; |
| 82 | 82 | } |
| 83 | ||
| 84 | export const PORTAL_URL_KEY = "billing.portal_url"; | |
| 85 | export const PORTAL_WORDS = "the billing portal address is one https URL with no spaces, or empty"; | |
| 86 | ||
| 87 | /** The provider's customer-portal sign-in, one https URL the host sets; "" when none. */ | |
| 88 | export function getPortalUrl(db: Database): string { | |
| 89 | const row = db.query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1").get(PORTAL_URL_KEY); | |
| 90 | return row === null ? "" : (parseLink(row.value) ?? ""); | |
| 91 | } | |
| 92 | ||
| 93 | /** Keep the portal URL (validated, empty clears it) with who changed it; the same value again keeps nothing. */ | |
| 94 | export function setPortalUrl(db: Database, raw: unknown, actorId: string): Result<string, "invalid_portal_url"> { | |
| 95 | const link = parseLink(raw); | |
| 96 | if (link === undefined) return err("invalid_portal_url"); | |
| 97 | const from = getPortalUrl(db); | |
| 98 | if (from !== link) writeSetting(db, PORTAL_URL_KEY, link === "" ? null : link, from, link, actorId); | |
| 99 | return ok(link); | |
| 100 | } |
src/tui/screens/entries.ts +2
| @@ -50,6 +50,7 @@ import { entry as homePins } from "./home_pins.ts"; | ||
| 50 | 50 | import { entry as claimStart } from "./claim_start.ts"; |
| 51 | 51 | import { entry as invites } from "./invites.ts"; |
| 52 | 52 | import { entry as billingLinks } from "./billinglinks.ts"; |
| 53 | import { entry as planRow } from "./planrow.ts"; | |
| 53 | 54 | import { entry as limits } from "./limits.ts"; |
| 54 | 55 | import { entry as plans } from "./plans.ts"; |
| 55 | 56 | import { entry as economics } from "./economics.ts"; |
| @@ -162,6 +163,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 162 | 163 | archived, |
| 163 | 164 | invites, |
| 164 | 165 | billingLinks, |
| 166 | planRow, | |
| 165 | 167 | claimStart, |
| 166 | 168 | requests, |
| 167 | 169 | bulk, |
src/tui/screens/planrow.ts added +66
| @@ -0,0 +1,66 @@ | ||
| 1 | /** | |
| 2 | * The Plan row in the terminal (#11141): the plan line a forge's admins see | |
| 3 | * at the top of /settings (`planLine`, the page's own words) and, for an | |
| 4 | * admin who is a person, the billing portal address with Enter to set it, | |
| 5 | * through `changePortalUrl` so the gate and the checks are the page's. | |
| 6 | */ | |
| 7 | ||
| 8 | import { changePortalUrl } from "../../actions/signup.ts"; | |
| 9 | import { getPortalUrl } from "../../settings/billing.ts"; | |
| 10 | import { planLine } from "../../web/plan_row.ts"; | |
| 11 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 12 | import { binding } from "../app/keys.ts"; | |
| 13 | import { style } from "../term/style.ts"; | |
| 14 | import { truncate } from "../term/width.ts"; | |
| 15 | import type { Session } from "../session.ts"; | |
| 16 | import { fill, len, split } from "../ui/layout.ts"; | |
| 17 | import { ListState, drawList, promptScreen } from "./common.ts"; | |
| 18 | import type { PaletteEntry } from "./entries.ts"; | |
| 19 | ||
| 20 | const person = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind === "user"; | |
| 21 | ||
| 22 | export function planRowScreen(f: Session, ctx: Ctx): Screen { | |
| 23 | const state = new ListState(); | |
| 24 | let note = ""; | |
| 25 | const screen: Screen = { | |
| 26 | title: "Plan", | |
| 27 | keys: [binding(["enter"], "set the billing portal address")], | |
| 28 | render(area, s) { | |
| 29 | const c = ctx.theme.c; | |
| 30 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 31 | const text = note !== "" ? note : (planLine(f.app.config.dataDir) ?? "This forge was told no plan."); | |
| 32 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 33 | drawList(s, body!, c, state, [{ title: "Billing portal", secondary: getPortalUrl(f.app.db) || "no link" }]); | |
| 34 | }, | |
| 35 | key(e) { | |
| 36 | note = ""; | |
| 37 | if (e.ctrl || e.alt || e.key !== "enter" || !person(f)) return false; | |
| 38 | ctx.push( | |
| 39 | promptScreen(ctx, screen, { | |
| 40 | title: "Billing portal", | |
| 41 | label: "https address, empty for none", | |
| 42 | value: getPortalUrl(f.app.db), | |
| 43 | onSubmit: (v) => { | |
| 44 | const set = changePortalUrl(f.app, f.user, v.trim()); | |
| 45 | if (!set.ok) return set.error === "not_found" ? "You cannot change the billing portal." : "The billing portal is one https address, or empty."; | |
| 46 | note = `Billing portal: ${set.value || "no link"}.`; | |
| 47 | ctx.invalidate(); | |
| 48 | return undefined; | |
| 49 | }, | |
| 50 | }), | |
| 51 | ); | |
| 52 | return true; | |
| 53 | }, | |
| 54 | }; | |
| 55 | return screen; | |
| 56 | } | |
| 57 | ||
| 58 | /** The plan line and the portal address: an admin of the forge. */ | |
| 59 | export const entry: PaletteEntry = { | |
| 60 | id: "plan-row", | |
| 61 | title: "Plan", | |
| 62 | hint: "this forge's plan, and where its owner manages billing", | |
| 63 | after: ["billing-links", "limits"], | |
| 64 | offered: (session) => session.user?.isAdmin === true, | |
| 65 | run: (session, ctx) => ctx.push(planRowScreen(session, ctx)), | |
| 66 | }; |
src/web/pages/account.ts +2 −1
| @@ -62,6 +62,7 @@ import { | ||
| 62 | 62 | import { accountSections } from "./origins.ts"; |
| 63 | 63 | import { field, readForm } from "../form.ts"; |
| 64 | 64 | import { html, when, type Html } from "../html.ts"; |
| 65 | import { planRow } from "../plan_row.ts"; | |
| 65 | 66 | import { flashTo, page } from "../layout.ts"; |
| 66 | 67 | import { readFlash } from "../flash.ts"; |
| 67 | 68 | import { |
| @@ -519,7 +520,7 @@ export function accountPage(ctx: Ctx): Response { | ||
| 519 | 520 | ctx, |
| 520 | 521 | "profile", |
| 521 | 522 | "Settings", |
| 522 | html`${pair}${profile}${edge}${parent}${notify}`, | |
| 523 | html`${planRow(ctx.app.config.dataDir, parentIssuerOf(ctx.app.config), user.isAdmin === true)}${pair}${profile}${edge}${parent}${notify}`, | |
| 523 | 524 | 200, |
| 524 | 525 | // The everyday form makes its account with a passkey (#1890). |
| 525 | 526 | pair ? passkeyJs : undefined, |
src/web/pages/billing.ts +30 −3
| @@ -15,9 +15,9 @@ import { publicOrigin } from "../../core/public_origin.ts"; | ||
| 15 | 15 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 16 | 16 | import type { Ctx } from "../../http/context.ts"; |
| 17 | 17 | import { SIZES } from "../../spawn/sizes.ts"; |
| 18 | import { BILLING_SIZES, parseBillingLinks } from "../../settings/billing.ts"; | |
| 18 | import { BILLING_SIZES, getPortalUrl, PORTAL_WORDS, parseBillingLinks } from "../../settings/billing.ts"; | |
| 19 | 19 | import { getPlans } from "../../settings/plans.ts"; |
| 20 | import { changeBillingLinks, readBillingLinks } from "../../actions/signup.ts"; | |
| 20 | import { changeBillingLinks, changePortalUrl, readBillingLinks } from "../../actions/signup.ts"; | |
| 21 | 21 | import { funnelReport, type FunnelReport } from "../../perf/funnel.ts"; |
| 22 | 22 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| 23 | 23 | import { field, readForm } from "../form.ts"; |
| @@ -106,6 +106,32 @@ function linksSection(ctx: Ctx, retry?: { values: Record<string, string>; error: | ||
| 106 | 106 | </section>`; |
| 107 | 107 | } |
| 108 | 108 | |
| 109 | /** The one billing-portal address every paying forge's Plan row links to. */ | |
| 110 | function portalSection(ctx: Ctx, retry?: { value: string; error: string }): Html | null { | |
| 111 | if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") return null; | |
| 112 | return html`<section class="settings-block" id="portal"> | |
| 113 | <h2>Billing portal</h2> | |
| 114 | <p class="muted small">Stripe's customer-portal sign-in. A forge that pays through Stripe links its owner here to change a card; empty shows no link.</p> | |
| 115 | ${retry === undefined ? null : html`<p class="error" role="alert">${retry.error}</p>`} | |
| 116 | <form class="stack" method="post" action="${PATH}/portal"> | |
| 117 | ${formField({ label: "Portal sign-in address", name: "portal_url", type: "url", value: retry?.value ?? getPortalUrl(ctx.app.db), hint: "https://billing.stripe.com/p/login/…" })} | |
| 118 | ${btn("Save portal address", { variant: "primary" })} | |
| 119 | </form> | |
| 120 | </section>`; | |
| 121 | } | |
| 122 | ||
| 123 | // POST /settings/billing/portal | |
| 124 | export async function billingSetPortal(ctx: Ctx): Promise<Response> { | |
| 125 | if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") return notFoundPage(ctx); | |
| 126 | const form = await readForm(ctx, "admin"); | |
| 127 | if (form instanceof Response) return form; | |
| 128 | const value = field(form, "portal_url"); | |
| 129 | const changed = changePortalUrl(ctx.app, ctx.auth.user, value); | |
| 130 | if (!changed.ok && changed.error === "not_found") return notFoundPage(ctx); | |
| 131 | if (changed.ok) return flashTo(ctx, `${PATH}#portal`, { message: "Billing portal address saved." }); | |
| 132 | return billingView(ctx, undefined, {}, undefined, 422, undefined, { value, error: `${PORTAL_WORDS}. Nothing was saved.` }); | |
| 133 | } | |
| 134 | ||
| 109 | 135 | // POST /settings/billing/links |
| 110 | 136 | export async function billingSetLinks(ctx: Ctx): Promise<Response> { |
| 111 | 137 | if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") return notFoundPage(ctx); |
| @@ -144,7 +170,7 @@ export function billingPage(ctx: Ctx): Response { | ||
| 144 | 170 | return billingView(ctx); |
| 145 | 171 | } |
| 146 | 172 | |
| 147 | function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = {}, error?: string, status = 200, links?: { values: Record<string, string>; error: string }): Response { | |
| 173 | function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = {}, error?: string, status = 200, links?: { values: Record<string, string>; error: string }, portal?: { value: string; error: string }): Response { | |
| 148 | 174 | const read = readBilling(ctx.app, ctx.auth.user); |
| 149 | 175 | if (!read.ok) return notFoundPage(ctx); |
| 150 | 176 | const b = read.value; |
| @@ -213,6 +239,7 @@ function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = { | ||
| 213 | 239 | ${ledBlock(ctx)} |
| 214 | 240 | ${funnelRow(funnelReport(ctx.app.db, 7))} |
| 215 | 241 | ${linksSection(ctx, links)} |
| 242 | ${portalSection(ctx, portal)} | |
| 216 | 243 | ${codesSection(ctx, shown, values, error)}`; |
| 217 | 244 | const response = page({ |
| 218 | 245 | ctx, |
src/web/plan_row.ts added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | /** | |
| 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. | |
| 9 | */ | |
| 10 | ||
| 11 | import { noticeOf, type PlanNotice } from "../billing/notice.ts"; | |
| 12 | import { fundingOf } from "../spawn/funding.ts"; | |
| 13 | import { usdWords } from "../spawn/credit.ts"; | |
| 14 | import { sizeWords } from "../settings/billing.ts"; | |
| 15 | import { isSize } from "../spawn/sizes.ts"; | |
| 16 | import { html, type Html } from "./html.ts"; | |
| 17 | ||
| 18 | const day = (secs: number): string => new Date(secs * 1000).toISOString().slice(0, 10); | |
| 19 | ||
| 20 | /** "Active", or "Past due, sleeps 2026-12-01". */ | |
| 21 | 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)}`; | |
| 23 | return n.status === "none" ? "No active plan" : "Active"; | |
| 24 | } | |
| 25 | ||
| 26 | /** The plan as one plain line of text, or undefined when this forge was told none; the page and the terminal say the same. */ | |
| 27 | export function planLine(dataDir: string): string | undefined { | |
| 28 | const n = noticeOf(dataDir); | |
| 29 | if (n === undefined) return undefined; | |
outdated
Principles 1, 3, 6, 7: a fact is a |
||
| 30 | const f = fundingOf(dataDir); | |
| 31 | const size = isSize(n.size) ? ` (${sizeWords(n.size)})` : ""; | |
| 32 | const credit = n.creditUsd > 0 && f?.spentUsd != null ? ` ${usdWords(f.spentUsd)} of ${usdWords(n.creditUsd)} crew credit used this month.` : ""; | |
| 33 | return `${n.name}${size}. ${statusWords(n)}.${credit}`; | |
| 34 | } | |
| 35 | ||
| 36 | /** One `dl` row for a forge's admins; null for anyone else or a forge told nothing. */ | |
| 37 | export function planRow(dataDir: string, parentOrigin: string | undefined, admin: boolean): Html | null { | |
| 38 | if (!admin) return null; | |
| 39 | const line = planLine(dataDir); | |
| 40 | if (line === undefined) return null; | |
| 41 | const portal = noticeOf(dataDir)?.portalUrl; | |
| 42 | 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>`; | |
| 45 | } |
test/palette-roster.test.ts +2
| @@ -155,6 +155,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 155 | 155 | ["flows-all", "Flows across the forge", "every repository's flow lines, by kind and stale"], |
| 156 | 156 | ["teams", "Crew teams", "each team's charter, dial and members"], |
| 157 | 157 | ["billing-links", "Buy links", "where each hosted plan is paid for, shown on the home page"], |
| 158 | ["plan-row", "Plan", "this forge's plan, and where its owner manages billing"], | |
| 158 | 159 | ["agents", "Crew agents", "each agent's model, budget and brief"], |
| 159 | 160 | ["invites", "Invite links", "make a single-use link, see which are open, revoke one"], |
| 160 | 161 | ["claim-start", "Take an agent's work", "claim the repositories an agent started, from its one-time link"], |
| @@ -260,6 +261,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 260 | 261 | ["traffic-sources", "Traffic sources", "where people come from and where they go, for the forge"], |
| 261 | 262 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 262 | 263 | ["archived", "Archived repositories", "put away, not gone: open one or restore it"], |
| 264 | ["plan-row", "Plan", "this forge's plan, and where its owner manages billing"], | |
| 263 | 265 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| 264 | 266 | ["pinned", "Pinned forges", "deploy, update and roll back a forge on your cloud"], |
| 265 | 267 | ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes, h who liked"], |
test/plan-notice.test.ts added +111
| @@ -0,0 +1,111 @@ | ||
| 1 | /** | |
| 2 | * A forge's Plan row (#11141): the host tells each forge its own plan (name, | |
| 3 | * size, status, the public portal URL) and nothing of any other; the row | |
| 4 | * shows it to the forge's admins, in one line, with Manage billing only when | |
| 5 | * a portal URL is set and the forge pays through Stripe. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { childDirOf } from "../src/actions/spawn.ts"; | |
| 12 | import { changePortalUrl } from "../src/actions/signup.ts"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { noticeOf, tellPlan } from "../src/billing/notice.ts"; | |
| 15 | import { putEntitlement } from "../src/billing/store.ts"; | |
| 16 | import { createUser, type User } from "../src/core/users.ts"; | |
| 17 | import { getPortalUrl } from "../src/settings/billing.ts"; | |
| 18 | import { planRow, statusWords } from "../src/web/plan_row.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | describe("plan notice", () => { | |
| 24 | let dir: string; | |
| 25 | let app: App; | |
| 26 | let admin: User; | |
| 27 | const PORTAL = "https://billing.stripe.com/p/login/abc123"; | |
| 28 | ||
| 29 | const child = async (name: string): Promise<string> => { | |
| 30 | const bodyId = `body-${name}`; | |
| 31 | await mkdir(join(dir, "spawn"), { recursive: true }); | |
| 32 | await writeFile(join(dir, "spawn", `${name}.json`), JSON.stringify({ name, bodyId, done: true, size: "small", org: name, apps: [] })); | |
| 33 | const cd = childDirOf(app, bodyId); | |
| 34 | await mkdir(cd, { recursive: true }); | |
| 35 | await writeFile(join(cd, "parent.json"), JSON.stringify({ upstream: "https://host.example" })); | |
| 36 | return cd; | |
| 37 | }; | |
| 38 | ||
| 39 | beforeAll(async () => { | |
| 40 | dir = await mkdtemp(join(tmpdir(), "forge-notice-")); | |
| 41 | app = createApp({ dataDir: dir, port: 0 }); | |
| 42 | const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 43 | if (!a.ok) throw new Error("user"); | |
| 44 | admin = a.value; | |
| 45 | }); | |
| 46 | afterAll(async () => { | |
| 47 | app.close?.(); | |
| 48 | await rm(dir, { recursive: true, force: true }); | |
| 49 | }); | |
| 50 | ||
| 51 | test("the portal URL is https only, kept for admins, and empty clears it", () => { | |
| 52 | expect(changePortalUrl(app, admin, "http://x.example/").ok).toBe(false); | |
| 53 | expect(changePortalUrl(app, admin, "https://u:[email protected]/").ok).toBe(false); | |
| 54 | expect(changePortalUrl(app, { ...admin, isAdmin: false }, PORTAL)).toEqual({ ok: false, error: "not_found" }); | |
| 55 | expect(getPortalUrl(app.db)).toBe(""); | |
| 56 | expect(changePortalUrl(app, admin, PORTAL).ok).toBe(true); | |
| 57 | expect(getPortalUrl(app.db)).toBe(PORTAL); | |
| 58 | }); | |
| 59 | ||
| 60 | test("a forge is told its own plan, and only its own; a Stripe payer gets the portal link", async () => { | |
| 61 | const a = await child("alpha"); | |
| 62 | const b = await child("beta"); | |
| 63 | putEntitlement(app.db, { forge: "alpha", plan: "medium", status: "grace", periodEnd: 1_796_000_000, provider: "stripe", customerId: "cus_secret1", subscriptionId: "sub_secret1", source: "stripe" }, "small", ""); | |
| 64 | expect(tellPlan(app, "alpha")).toBe(true); | |
| 65 | const n = noticeOf(a)!; | |
| 66 | expect(n).toMatchObject({ name: "Medium", size: "medium", status: "grace", portalUrl: PORTAL }); | |
| 67 | // beta was never told: no call and no file of alpha's reaches it. | |
| 68 | expect(noticeOf(b)).toBeUndefined(); | |
| 69 | tellPlan(app, "beta"); | |
| 70 | const told = noticeOf(b)!; | |
| 71 | expect(told.name).toBe("Small"); | |
| 72 | expect(told.portalUrl).toBeUndefined(); | |
| 73 | // Nothing of another forge, nor any provider id, is in what a forge holds. | |
| 74 | for (const d of [a, b]) { | |
| 75 | const raw = await readFile(join(d, "plan.json"), "utf8"); | |
| 76 | expect(raw).not.toContain("cus_secret1"); | |
| 77 | expect(raw).not.toContain("sub_secret1"); | |
| 78 | expect(raw).not.toContain(d === a ? "beta" : "alpha"); | |
| 79 | } | |
| 80 | expect(tellPlan(app, "nobody")).toBe(false); | |
| 81 | }); | |
| 82 | ||
| 83 | test("the row is one line for admins only, with the status date and the links", async () => { | |
| 84 | const a = childDirOf(app, "body-alpha"); | |
| 85 | const row = planRow(a, "https://host.example", true)!.toString(); | |
| 86 | expect(row.split("\n").length).toBe(1); | |
| 87 | expect(row).toContain("<dt>Plan</dt>"); | |
| 88 | expect(row).not.toContain(" · "); | |
| 89 | expect(row).toContain("Medium"); | |
| 90 | expect(row).toContain("Past due, sleeps 2026-"); | |
| 91 | expect(row).toContain(`href="${PORTAL}"`); | |
| 92 | expect(row).toContain('href="https://host.example/pricing"'); | |
| 93 | expect(planRow(a, "https://host.example", false)).toBeNull(); | |
| 94 | // No portal URL for a forge that does not pay through Stripe: the plan, no link. | |
| 95 | const free = planRow(childDirOf(app, "body-beta"), undefined, true)!.toString(); | |
| 96 | expect(free).toContain("Small"); | |
| 97 | expect(free).not.toContain("Manage billing"); | |
| 98 | expect(free).not.toContain("Change size"); | |
| 99 | // A forge told nothing shows no row. | |
| 100 | expect(planRow(join(dir, "nowhere"), undefined, true)).toBeNull(); | |
| 101 | }); | |
| 102 | ||
| 103 | test("a hostile notice is refused on read", async () => { | |
| 104 | const cd = childDirOf(app, "body-beta"); | |
| 105 | await writeFile(join(cd, "plan.json"), JSON.stringify({ name: "x", size: "small", status: "active", portalUrl: "javascript:alert(1)" })); | |
| 106 | expect(noticeOf(cd)?.portalUrl).toBeUndefined(); | |
| 107 | await writeFile(join(cd, "plan.json"), JSON.stringify({ name: "x", size: "small", status: "bogus" })); | |
| 108 | expect(noticeOf(cd)).toBeUndefined(); | |
| 109 | expect(statusWords({ status: "active" })).toBe("Active"); | |
| 110 | }); | |
| 111 | }); |
test/plan-wiring.test.ts added +131
| @@ -0,0 +1,131 @@ | ||
| 1 | /** | |
| 2 | * The Plan row's wiring (#11141): every change to a forge's plan tells that | |
| 3 | * forge (grant, gift, checkout, Stripe sync), a new portal address tells every | |
| 4 | * forge, the portal setting is a person admin's alone and same-origin, and | |
| 5 | * /settings shows the row to an admin and to no one else. Each test fails if | |
| 6 | * the call it names is removed. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 9 | import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { grantPlan } from "../src/actions/billing.ts"; | |
| 13 | import { makeCode, settleGift } from "../src/actions/codes.ts"; | |
| 14 | import { changePortalUrl } from "../src/actions/signup.ts"; | |
| 15 | import { childDirOf } from "../src/actions/spawn.ts"; | |
| 16 | import { createApp, type App } from "../src/app.ts"; | |
| 17 | import { bindCheckout } from "../src/billing/checkout_completed.ts"; | |
| 18 | import { sealKey } from "../src/billing/connection.ts"; | |
| 19 | import { keepNotice, noticeOf } from "../src/billing/notice.ts"; | |
| 20 | import { putEntitlement } from "../src/billing/store.ts"; | |
| 21 | import { setStripeFetch, syncSubscription } from "../src/billing/sync.ts"; | |
| 22 | import { createSession } from "../src/core/sessions.ts"; | |
| 23 | import { createUser, type User } from "../src/core/users.ts"; | |
| 24 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 25 | import { getPortalUrl } from "../src/settings/billing.ts"; | |
| 26 | import { quietLogs } from "./helpers/globals.ts"; | |
| 27 | import { promote } from "./helpers/members.ts"; | |
| 28 | ||
| 29 | quietLogs(); | |
| 30 | ||
| 31 | const PORTAL = "https://billing.stripe.com/p/login/wired"; | |
| 32 | let dir: string; | |
| 33 | let app: App; | |
| 34 | let servers: Servers; | |
| 35 | let admin: User; | |
| 36 | let member: User; | |
| 37 | ||
| 38 | async function child(name: string): Promise<string> { | |
| 39 | const bodyId = `body-${name}`; | |
| 40 | await mkdir(join(dir, "spawn"), { recursive: true }); | |
| 41 | await writeFile(join(dir, "spawn", `${name}.json`), JSON.stringify({ name, bodyId, done: true, size: "small", org: name, apps: [] })); | |
| 42 | const cd = childDirOf(app, bodyId); | |
| 43 | await mkdir(cd, { recursive: true }); | |
| 44 | await writeFile(join(cd, "parent.json"), JSON.stringify({ upstream: "https://host.example" })); | |
| 45 | return cd; | |
| 46 | } | |
| 47 | ||
| 48 | beforeAll(async () => { | |
| 49 | dir = await mkdtemp(join(tmpdir(), "forge-wiring-")); | |
| 50 | app = createApp({ dataDir: dir, port: 0 }); | |
| 51 | servers = await serve(app); | |
| 52 | const make = async (username: string) => { | |
| 53 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", allowReserved: true, adminIfFirst: true }); | |
| 54 | if (!made.ok) throw new Error(made.error); | |
| 55 | return made.value; | |
| 56 | }; | |
| 57 | admin = await make("owner"); | |
| 58 | promote(app.db, admin); | |
| 59 | member = await make("mia"); | |
| 60 | }); | |
| 61 | afterAll(async () => { | |
| 62 | setStripeFetch(app, undefined); | |
| 63 | await servers.stop(); | |
| 64 | await rm(dir, { recursive: true, force: true }); | |
| 65 | }); | |
| 66 | ||
| 67 | test("a grant tells that forge its plan", async () => { | |
| 68 | const cd = await child("g1"); | |
| 69 | expect(noticeOf(cd)).toBeUndefined(); | |
| 70 | expect(grantPlan(app, admin, "g1", "medium").ok).toBe(true); | |
| 71 | expect(noticeOf(cd)).toMatchObject({ name: "Medium", status: "active" }); | |
| 72 | }); | |
| 73 | ||
| 74 | test("a redeemed gift tells the forge its plan", async () => { | |
| 75 | const cd = await child("g2"); | |
| 76 | const made = makeCode(app, admin, { plan: "large" }, "http://x"); | |
| 77 | if (!made.ok) throw new Error(made.error); | |
| 78 | settleGift(app, made.value.made, "g2"); | |
| 79 | expect(noticeOf(cd)).toMatchObject({ name: "Large", status: "active" }); | |
| 80 | }); | |
| 81 | ||
| 82 | test("a completed checkout and a Stripe sync tell the forge, with the portal link for a payer", async () => { | |
| 83 | expect(changePortalUrl(app, admin, PORTAL).ok).toBe(true); | |
| 84 | const cd = await child("g3"); | |
| 85 | const bound = bindCheckout(app, { data: { object: { mode: "subscription", payment_status: "paid", customer: "cus_w", subscription: "sub_w", metadata: { forge: "g3", plan: "medium" } } } }); | |
| 86 | expect(bound).toBe(true); | |
| 87 | expect(noticeOf(cd)).toMatchObject({ name: "Medium", status: "active", portalUrl: PORTAL }); | |
| 88 | ||
| 89 | sealKey(app.db, dir, "sk_test_abcdefgh12345678"); | |
| 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 | await syncSubscription(app, "sub_w"); | |
| 92 | expect(noticeOf(cd)).toMatchObject({ status: "grace", periodEnd: 1900000000 }); | |
| 93 | }); | |
| 94 | ||
| 95 | test("a new portal address tells every forge; clearing it removes the link", async () => { | |
| 96 | const a = await child("t1"); | |
| 97 | const b = await child("t2"); | |
| 98 | for (const f of ["t1", "t2"]) putEntitlement(app.db, { forge: f, plan: "medium", status: "active", source: "stripe", provider: "stripe", customerId: `cus_${f}`, subscriptionId: `sub_${f}` }, "small", ""); | |
| 99 | const next = "https://billing.stripe.com/p/login/second"; | |
| 100 | expect(changePortalUrl(app, admin, next).ok).toBe(true); | |
| 101 | expect(noticeOf(a)?.portalUrl).toBe(next); | |
| 102 | expect(noticeOf(b)?.portalUrl).toBe(next); | |
| 103 | expect(changePortalUrl(app, admin, "").ok).toBe(true); | |
| 104 | expect(noticeOf(a)?.portalUrl).toBeUndefined(); | |
| 105 | expect(noticeOf(b)?.portalUrl).toBeUndefined(); | |
| 106 | }); | |
| 107 | ||
| 108 | test("the portal setting refuses a member, an agent and a cross-origin post; nothing changes", async () => { | |
| 109 | const before = getPortalUrl(app.db); | |
| 110 | expect(changePortalUrl(app, member, PORTAL)).toEqual({ ok: false, error: "not_found" }); | |
| 111 | expect(changePortalUrl(app, { ...admin, kind: "agent" }, PORTAL)).toEqual({ ok: false, error: "not_found" }); | |
| 112 | const post = (cookie: string, origin: string) => | |
| 113 | fetch(`${servers.url}/settings/billing/portal`, { method: "POST", headers: { cookie, origin, accept: "text/html" }, body: new URLSearchParams({ portal_url: PORTAL }), redirect: "manual" }); | |
| 114 | const mine = `forge_session=${createSession(app.db, member.id).token}`; | |
| 115 | const theirs = `forge_session=${createSession(app.db, admin.id).token}`; | |
| 116 | expect((await post(mine, servers.url)).status).toBe(404); | |
| 117 | expect((await post(theirs, "https://evil.test")).status).toBe(403); | |
| 118 | expect(getPortalUrl(app.db)).toBe(before); | |
| 119 | expect((await post(theirs, servers.url)).status).toBe(303); | |
| 120 | expect(getPortalUrl(app.db)).toBe(PORTAL); | |
| 121 | }); | |
| 122 | ||
| 123 | test("/settings shows the Plan row to an admin and to a member not at all", async () => { | |
| 124 | await writeFile(join(dir, "parent.json"), JSON.stringify({ upstream: "https://host.example" })); | |
| 125 | expect(keepNotice(dir, { name: "Medium", size: "medium", priceUsd: 49, creditUsd: 10, status: "active", portalUrl: PORTAL })).toBe(true); | |
| 126 | const get = async (id: string) => (await fetch(`${servers.url}/settings`, { headers: { cookie: `forge_session=${createSession(app.db, id).token}` } })).text(); | |
| 127 | const seen = await get(admin.id); | |
| 128 | expect(seen).toContain("<dt>Plan</dt>"); | |
| 129 | expect(seen).toContain(`href="${PORTAL}"`); | |
| 130 | expect(await get(member.id)).not.toContain("<dt>Plan</dt>"); | |
| 131 | }); |
test/spawn.test.ts +2
| @@ -93,6 +93,8 @@ describe("spawnForge", () => { | ||
| 93 | 93 | // The plan's included credit, lent once (#11154). |
| 94 | 94 | credit: { capUsd: 2, spentUsd: 0, token: expect.stringMatching(/^kid\./) }, |
| 95 | 95 | }); |
| 96 | // Its own plan, told once when it was made (#11141): a file of its own, never parent.json. | |
| 97 | expect(JSON.parse(await Bun.file(join(child, "plan.json")).text())).toMatchObject({ name: "Small", size: "small", status: "active" }); | |
| 96 | 98 | }); |
| 97 | 99 | |
| 98 | 100 | test("no secret arrives", async () => { |
test/tui-plan-row.test.ts added +87
| @@ -0,0 +1,87 @@ | ||
| 1 | /** | |
| 2 | * The Plan row in the terminal (#11141): an admin reads the plan line and | |
| 3 | * sets the billing portal through the page's own action; a member is not | |
| 4 | * offered the screen. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 11 | import { keepNotice } from "../src/billing/notice.ts"; | |
| 12 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 13 | import { getPortalUrl } from "../src/settings/billing.ts"; | |
| 14 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 15 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 16 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | import { settleApp } from "./helpers/tui.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | let dir: string; | |
| 23 | let forge: Forge; | |
| 24 | ||
| 25 | function boot(username: string) { | |
| 26 | const user = getUserByName(forge.db, username)!; | |
| 27 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 28 | const { app } = createForgeApp(forge, identity, { terminal: headlessTerminal(140, 32), exitHooks: false }); | |
| 29 | app.start(); | |
| 30 | const k = (key: string) => app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 31 | return { | |
| 32 | text: () => app.screen.toText(), | |
| 33 | press: async (...keys: string[]) => { | |
| 34 | keys.forEach((x) => k(x)); | |
| 35 | await settleApp(app); | |
| 36 | }, | |
| 37 | open: async () => { | |
| 38 | app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false }); | |
| 39 | for (const ch of "Plan") k(ch); | |
| 40 | k("enter"); | |
| 41 | await settleApp(app); | |
| 42 | }, | |
| 43 | stop: () => app.stop(), | |
| 44 | }; | |
| 45 | } | |
| 46 | ||
| 47 | beforeAll(async () => { | |
| 48 | dir = await mkdtemp(join(tmpdir(), "forge-tui-plan-")); | |
| 49 | forge = createApp({ dataDir: dir, port: 0 }); | |
| 50 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 51 | const made = await createUser(forge.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst }); | |
| 52 | if (!made.ok) throw new Error(made.error); | |
| 53 | } | |
| 54 | await writeFile(join(dir, "parent.json"), JSON.stringify({ upstream: "https://host.example" })); | |
| 55 | keepNotice(dir, { name: "Medium", size: "medium", priceUsd: 49, creditUsd: 10, status: "grace", periodEnd: 1_900_000_000 }); | |
| 56 | }); | |
| 57 | afterAll(async () => { | |
| 58 | forge.close(); | |
| 59 | await rm(dir, { recursive: true, force: true }); | |
| 60 | }); | |
| 61 | ||
| 62 | test("an admin reads the plan line and sets the portal; a bad address keeps nothing", async () => { | |
| 63 | const h = boot("alice"); | |
| 64 | await h.open(); | |
| 65 | expect(h.text()).toContain("Medium"); | |
| 66 | expect(h.text()).toContain("Past due, sleeps 2030-"); | |
| 67 | await h.press("enter"); | |
| 68 | for (const ch of "http://insecure.test") await h.press(ch); | |
| 69 | await h.press("enter"); | |
| 70 | expect(getPortalUrl(forge.db)).toBe(""); | |
| 71 | await h.press("esc"); | |
| 72 | h.stop(); | |
| 73 | const again = boot("alice"); | |
| 74 | await again.open(); | |
| 75 | await again.press("enter"); | |
| 76 | for (const ch of "https://billing.test/login") await again.press(ch); | |
| 77 | await again.press("enter"); | |
| 78 | expect(getPortalUrl(forge.db)).toBe("https://billing.test/login"); | |
| 79 | again.stop(); | |
| 80 | }); | |
| 81 | ||
| 82 | test("a member is not offered the screen", async () => { | |
| 83 | const h = boot("bob"); | |
| 84 | await h.open(); | |
| 85 | expect(h.text()).not.toContain("Billing portal"); | |
| 86 | h.stop(); | |
| 87 | }); |
new above
offline
Both doors: setting the portal address has no terminal entry. Add a screen under src/tui/ (admin entry, same gate as this function), or a
No door: <why>commit line. Also add a test that tellAll reaches every forge.