Public: anyone can read this; the crew can change it.
/pricing sells a forge: pick a plan and a name, pay, and the claim link appears #11333
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11138-pricing-sells-a-forge-pick-a-plan-and-a → main 17 commits fork 1 thread · 1 unresolved
26 files
changed +1114
−48
docs/SPAWN.md +11 −1
| @@ -72,7 +72,7 @@ Children report in; the parent never reaches in. Every ten minutes a spawned chi | ||
| 72 | 72 | |
| 73 | 73 | ## Callers |
| 74 | 74 | |
| 75 | `POST /api/v1/forges {name, apps[], crew[], direction?, keepAccess?, modelKey?, budgetUsd?, size?, template?, dryRun}` (rw token, admin only) and the MCP `spawn` tool share `src/spawn/door.ts`. `size` is `small` (default), `medium` or `large`; anything else is a 422 on `size`. It is recorded in the plan, the ledger, `parent.json` and `GET /api/v1/forges`, but not enforced yet: it limits nothing. `dryRun` is true unless it is `false`: the answer is the plan, what is left behind, and `firstAdmin` (the caller's username, or `null` for an anonymous plan, where whoever claims the child becomes its admin), and nothing is made. `GET /api/v1/forges` lists the ledger's children. The child's org is its name; `crew` names the agents whose briefs travel. Errors: 401 unauthorized, 403 forbidden / cross_origin, 422 invalid, 409 refused / exists, 500 failed — each with one sentence. The MCP lets an `ro` token plan, never make. | |
| 75 | `POST /api/v1/forges {name, apps[], crew[], direction?, keepAccess?, modelKey?, budgetUsd?, size?, template?, dryRun}` (rw token; a person who is signed in, and a paid plan only from an admin, a gift code, or a paid order below) and the MCP `spawn` tool share `src/spawn/door.ts`. `size` is `small` (default), `medium` or `large`; anything else is a 422 on `size`. It is recorded in the plan, the ledger, `parent.json` and `GET /api/v1/forges`, but not enforced yet: it limits nothing. `dryRun` is true unless it is `false`: the answer is the plan, what is left behind, and `firstAdmin` (the caller's username, or `null` for an anonymous plan, where whoever claims the child becomes its admin), and nothing is made. `GET /api/v1/forges` lists the ledger's children. The child's org is its name; `crew` names the agents whose briefs travel. Errors: 401 unauthorized, 403 forbidden / cross_origin, 422 invalid, 409 refused / exists, 500 failed — each with one sentence. The MCP lets an `ro` token plan, never make. | |
| 76 | 76 | |
| 77 | 77 | A spawn may also name a `size`: `small`, `medium` or `large` (`src/spawn/sizes.ts` maps each to CPU, memory and disk). It is recorded in the plan only when given, so a plan without one hashes as it always did, and an unknown size is a 422 on `size`. The New forge page and the terminal form both offer it, small preselected and not sent; the page shows a refusal beside the select, linked with `aria-describedby`. Nothing enforces the limits yet, and the table's numbers are provisional. `GET /api/v1/forges/sizes` (no sign-in, ETag, cached for 5 minutes) and the MCP tool `sizes` give the same answer — each size's CPU, memory and disk, read from that table, and one sentence on what it is for — so a page or an agent offering a size reads it there. |
| 78 | 78 | |
| @@ -196,3 +196,13 @@ ledger (`spawn/sizes.ts`). **Unset means no quota.** A spawn that would pass a | ||
| 196 | 196 | declared limit is a 409 `refused` with one sentence naming it, and the New |
| 197 | 197 | forge page shows that sentence above the form when even a small forge would |
| 198 | 198 | not fit (`spawn/quota.ts`). |
| 199 | ||
| 200 | ## The paid path: `/pricing` | |
| 201 | ||
| 202 | A visitor with no account buys a forge. `GET /pricing` (public) lists the plans from the setting with their size, price and crew credit. A paid plan's button posts the forge's name and an email to `POST /pricing/order`, which checks the name is free (not a user, a child, a reserved word or another order's hold), writes an **order** (`forge_order`: name, plan, email, state `pending`) that holds the name for 30 minutes, and redirects to the provider's checkout. The buyer's ticket is an HttpOnly cookie scoped to `/pricing`; only its hash is kept. | |
| 203 | ||
| 204 | When the order turns paid (the `checkout.session.completed` webhook, or `/pricing/done` asking the provider for the session, whichever is first) one `UPDATE … WHERE state = 'pending'` wins and goes on: `spawnDoor(..., { paid: true })` makes the forge on that plan, the entitlement is written, the order is `spawned`. A second delivery finds the order moved and changes nothing. | |
| 205 | ||
| 206 | `/pricing/done` waits for the spawn, then mints a fresh claim link and shows it once, as `/forges/new` does; when mail is configured it is also mailed to the order's email. `/pricing/resend` re-sends a new link (the old one stops) to the order's own email only, and answers the same whether or not an order matched. | |
| 207 | ||
| 208 | A paid order whose spawn fails (no capacity, a name taken in a race) becomes `failed`: a `billing.order_failed` event, the `paid_order` flow alarm, and a plain page to the buyer. On Settings → Billing an admin sees "Paid, not made" with **Retry** and a link to the provider's dashboard to refund. With no provider connected the paid plans show their size and say "ask the operator". Agents are refused as buyers. |
docs/design/audit.md +3 −1
| @@ -49,6 +49,9 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 49 | 49 | |
| 50 | 50 | - `/` — `pages/site.ts` (`home`, studio mode in `studio/home.ts`) · where I am, what needs me · `/front` (same page for anyone), `/board`, `/asks` · colours 3 in `site.ts`, 7 in `studio/home.ts`. |
| 51 | 51 | - `/front` — `studio/home.ts` (`studioDoor`) · the signed-out door, and the site admin's controls over it · `/` for a signed-out reader · colours 7. |
| 52 | - `/pricing` — `pages/pricing.ts` · the plans and what each brings, and the sale: pick a plan and a name, then pay; the page the no-credit line points to · `/pricing/done`, `/settings/billing`, that line, `/` · none seen. | |
| 53 | - `/pricing/done` — `pages/pricing.ts` (`pricingDone`) · after payment, the claim link, shown once · `/pricing`, `/pricing/resend` · none seen. | |
| 54 | - `/pricing/resend` — `pages/pricing.ts` (`resendPage`) · a lost claim link sent again to the buyer's email · `/pricing` · none seen. | |
| 52 | 55 | - `/explore` — `studio/explore.ts` · browse what the forge holds · `/search`, `/apps` · colours 2. |
| 53 | 56 | - `/board` — `pages/board.ts` (1,231 lines) · every app the viewer can see, with a wall mode · `/boards` (saved queries as panels), `/flow`, `/` · colour 1, sizes 6. |
| 54 | 57 | - `/board/returns` — `pages/returns.ts` · what the window's dollars bought · `/settings/subscriptions` spend tables, `/settings/usage` · none seen. |
| @@ -94,7 +97,6 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 94 | 97 | - `/family` — `pages/family.ts` · parent, this forge and children as a tree · `/forges` (the same children) · colour 1. |
| 95 | 98 | - `/claim` — `pages/claim.ts` · claim a forge that was sent to me · none · colour 1. |
| 96 | 99 | - `/privacy` — `pages/privacy.ts` · what the forge keeps about a visitor, three plain lines, open to anyone · none · none seen. |
| 97 | - `/pricing` — `pricing.ts` · the plans and what each brings, the page the no-credit line points to · that line, `/` · none seen. | |
| 98 | 100 | - `/first-admin` — `pages/first-admin.ts` · the first person becomes admin · none · none seen. |
| 99 | 101 | - `/fleet/graph` — `pages/fleet-graph.ts` · the fleet as a graph · `/family`, `/forges` · none seen. |
| 100 | 102 | - `/fleet` — `pages/fleet.ts` · the rows a fleet query line selects, as a table · `/fleet/graph` · none seen. |
docs/features/home-plans.md +1 −1
| @@ -24,4 +24,4 @@ The home reads who may sign up, the plans and the buy links in one statement (`s | ||
| 24 | 24 | |
| 25 | 25 | ## When the crew has no credit |
| 26 | 26 | |
| 27 | A forge whose crew has no key and no included credit says so once on its first screen: "The crew cannot build until you add a model key or pick a plan." (`NO_CREDIT_WORDS`, `crewFirstLine` in `src/web/studio/crew_key.ts`; the terminal's first screen says the same line as text, `crewFirstText`, with credit too). The key is a link to `/settings/subscriptions` (admins), the plan a link to `/pricing`, the plans on a page of their own (`src/web/pricing.ts`). With credit the line is the next step: make an app or bring a repository. A tell sent with no credit is kept, and the flash says the crew cannot build yet, with the same two ways forward; that holds for the first screen's own box (`app=forge`, kept as a thought) as for a tell to an app. `/pricing` has no terminal screen: its door there is the palette's *Get your own forge*. | |
| 27 | A forge whose crew has no key and no included credit says so once on its first screen: "The crew cannot build until you add a model key or pick a plan." (`NO_CREDIT_WORDS`, `crewFirstLine` in `src/web/studio/crew_key.ts`; the terminal's first screen says the same line as text, `crewFirstText`, with credit too). The key is a link to `/settings/subscriptions` (admins), the plan a link to `/pricing`, the plans, and the sale, on a page of their own (`src/web/pages/pricing.ts`). With credit the line is the next step: make an app or bring a repository. A tell sent with no credit is kept, and the flash says the crew cannot build yet, with the same two ways forward; that holds for the first screen's own box (`app=forge`, kept as a thought) as for a tell to an app. `/pricing` has no terminal screen: its door there is the palette's *Get your own forge*. |
docs/features/pricing.md added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | # Buy a forge at /pricing (#11138) | |
| 2 | ||
| 3 | Open `/pricing` with no account. Each plan shows its size, price and crew credit. Type the forge's name and an email, press the paid plan's button, pay at the provider's checkout, and the page you return to (`/pricing/done`) shows the claim link once. Open it to take the forge over. | |
| 4 | ||
| 5 | - **Free plan:** its button goes to `/forges/new`. | |
| 6 | - **Closed the tab?** `/pricing/resend`: name and email you bought with; a new link is mailed there (needs mail configured) and the old one stops. | |
| 7 | - **No provider connected:** paid plans show their size and say "Ask the operator". | |
| 8 | - **Operator:** connect Stripe under Settings → Billing. A paid order whose forge could not be made shows under "Paid, not made" with Retry and a link to the provider's dashboard to refund; a flow alarm names it. | |
| 9 | - **Limits:** one client address may have 3 orders waiting at once (counted by socket address, never a header); a name is held 35 minutes and the provider's checkout stops being payable at 31, so a late payer never finds the name given away; a claim link is mailed again at most once a day per order. | |
| 10 | - **Terminal:** no screen yet; checkout is browser-only (the provider's hosted page). Tracked in a follow-up issue. | |
| 11 | - **Code:** `src/actions/order.ts` (rules), `src/billing/orders.ts` (store), `src/web/pages/pricing.ts` (pages). The paid path is described in `docs/SPAWN.md`. | |
| 12 | - **Test:** `bun test test/pricing-order.test.ts`. |
src/actions/order.ts added +241
| @@ -0,0 +1,241 @@ | ||
| 1 | /** | |
| 2 | * The sale (#11138): a visitor picks a paid plan, a name and an email; the | |
| 3 | * name is held for 30 minutes (an order, `billing/orders.ts`) and the buyer | |
| 4 | * goes to the provider's checkout. When the order turns paid, by the webhook | |
| 5 | * or by the return page asking the provider for the session, whichever | |
| 6 | * comes first, the forge is made once through `spawnDoor` on that plan and | |
| 7 | * its entitlement written. A paid order whose forge cannot be made is | |
| 8 | * `failed`: an event, a flow alarm, and a plain sentence to the buyer; an | |
| 9 | * admin retries it or refunds at the provider. Never an agent as buyer. | |
| 10 | * | |
| 11 | * The claim link is shown once, on the return page, and mailed when the | |
| 12 | * forge can send mail. It is never kept: the page makes a fresh one when the | |
| 13 | * buyer arrives, and a buyer who left can ask for one by email. | |
| 14 | */ | |
| 15 | ||
| 16 | import type { App } from "../app.ts"; | |
| 17 | import { checkoutOf } from "../billing/checkout.ts"; | |
| 18 | import { getOrder, holdName, markResent, markShown, move, unmarkShown, pendingFrom, MAX_PENDING_PER_ADDRESS, CHECKOUT_MS, nameHeld, newId, newTicket, dropPending, pendingCount, setSession, spawnedFor, ticketFits, MAX_PENDING, type Order } from "../billing/orders.ts"; | |
| 19 | import { putEntitlement } from "../billing/store.ts"; | |
| 20 | import { getUserByName, type User } from "../core/users.ts"; | |
| 21 | import { publicOrigin } from "../core/public_origin.ts"; | |
| 22 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 23 | import { isChildName } from "../core/proposal_token.ts"; | |
| 24 | import type { Ctx } from "../http/context.ts"; | |
| 25 | import { spawnDoor } from "../spawn/door.ts"; | |
| 26 | import { FREE, getPlan, type Plan } from "../settings/plans.ts"; | |
| 27 | import { listChildrenNow, reissueClaimFor } from "./spawn.ts"; | |
| 28 | import { mayMail, sendCapped } from "./mail.ts"; | |
| 29 | import { mailerOf } from "./verify.ts"; | |
| 30 | ||
| 31 | export interface Refusal { | |
| 32 | readonly field: "name" | "email" | "plan" | "form"; | |
| 33 | readonly message: string; | |
| 34 | } | |
| 35 | ||
| 36 | const ADDRESS = /^[^\s<>@,;:"\\]{1,64}@[^\s<>@,;:"\\]{1,190}$/; | |
| 37 | export const NO_PROVIDER = "Paid plans are not on sale here yet: ask the operator."; | |
| 38 | /** A spawn that stays `spawning` this long is taken as lost and may be retried. */ | |
| 39 | const STALE_MS = 5 * 60_000; | |
| 40 | /** A claim link is mailed again at most once a day per order: a stranger cannot make it a mail-bomb. */ | |
| 41 | const RESEND_MS = 24 * 60 * 60_000; | |
| 42 | ||
| 43 | /** A request-less context the forge itself acts through when no visitor is on the line (a webhook). */ | |
| 44 | function systemCtx(app: App): Ctx { | |
| 45 | const origin = publicOrigin(app.config.url, `http://localhost:${app.config.port}`); | |
| 46 | return { | |
| 47 | app, | |
| 48 | request: new Request(`${origin}/pricing`, { method: "POST" }), | |
| 49 | url: new URL(`${origin}/pricing`), | |
| 50 | params: {}, | |
| 51 | auth: { user: undefined, scope: undefined, via: "none" }, | |
| 52 | theme: undefined, | |
| 53 | }; | |
| 54 | } | |
| 55 | ||
| 56 | /** The email, trimmed and lower-cased, or undefined. */ | |
| 57 | export const emailOf = (raw: unknown): string | undefined => { | |
| 58 | const s = typeof raw === "string" ? raw.trim() : ""; | |
| 59 | return s.length <= 254 && ADDRESS.test(s) ? s.toLowerCase() : undefined; | |
| 60 | }; | |
| 61 | ||
| 62 | /** Every refusal of a name, in one sentence, or undefined when it is free to buy. */ | |
| 63 | export async function nameRefusal(app: App, name: string, plan: Plan, now: number): Promise<string | undefined> { | |
| 64 | if (!isChildName(name) || name.length > 38) return "Use 1 to 38 lowercase letters, digits and dashes, not starting or ending with a dash"; | |
| 65 | if (getUserByName(app.db, name) !== undefined || listChildrenNow(app).some((c) => c.name === name)) return "That name is taken"; | |
| 66 | if (nameHeld(app.db, name, now)) return "That name is held for someone else; try another"; | |
| 67 | // The door's own plan: reserved words and the rest of the name's rules, with nothing made. | |
| 68 | const dry = await spawnDoor(systemCtx(app), undefined, { name, apps: [], crew: [], plan: plan.key, dryRun: true }, { paid: true }); | |
| 69 | if (dry.status >= 400) return String(dry.body["message"] ?? "That name cannot be used").replace(/^name: /, ""); | |
| 70 | return undefined; | |
| 71 | } | |
| 72 | ||
| 73 | export interface Placed { | |
| 74 | readonly id: string; | |
| 75 | /** Goes in the buyer's HttpOnly cookie; only its hash is kept. */ | |
| 76 | readonly ticket: string; | |
| 77 | /** The provider's checkout address. */ | |
| 78 | readonly redirect: string; | |
| 79 | } | |
| 80 | ||
| 81 | /** Validate, hold the name, make the checkout. Nothing is held if the checkout cannot be made. */ | |
| 82 | export async function placeOrder(app: App, input: { name: unknown; plan: unknown; email: unknown; address: string }, url: URL, now = Date.now()): Promise<Result<Placed, Refusal>> { | |
| 83 | const plan = typeof input.plan === "string" ? getPlan(app.db, input.plan) : undefined; | |
| 84 | if (plan === undefined || plan.key === FREE || plan.priceUsd <= 0) return err({ field: "plan", message: "Pick one of the paid plans" }); | |
| 85 | const email = emailOf(input.email); | |
| 86 | if (email === undefined) return err({ field: "email", message: "Enter the email the link should go to" }); | |
| 87 | const name = typeof input.name === "string" ? input.name.trim() : ""; | |
| 88 | const provider = checkoutOf(app); | |
| 89 | if (provider === undefined) return err({ field: "form", message: NO_PROVIDER }); | |
| 90 | if (pendingCount(app.db, now) >= MAX_PENDING) return err({ field: "form", message: "Many orders are waiting right now; try again in a few minutes" }); | |
| 91 | if (pendingFrom(app.db, input.address, now) >= MAX_PENDING_PER_ADDRESS) return err({ field: "form", message: "You have orders waiting already; finish one or try again in half an hour" }); | |
| 92 | const refused = await nameRefusal(app, name, plan, now); | |
| 93 | if (refused !== undefined) return err({ field: "name", message: refused }); | |
| 94 | const id = newId(); | |
| 95 | const ticket = newTicket(); | |
| 96 | if (!holdName(app.db, { id, name, plan: plan.key, email, ticket, address: input.address }, now)) return err({ field: "name", message: "That name is held for someone else; try another" }); | |
| 97 | const origin = publicOrigin(app.config.url, url.origin); | |
| 98 | const made = await provider.create({ | |
| 99 | orderId: id, forge: name, plan: plan.key, planName: plan.name, priceUsd: plan.priceUsd, email, | |
| 100 | successUrl: `${origin}/pricing/done?order=${id}`, cancelUrl: `${origin}/pricing`, | |
| 101 | // The checkout stops being payable before the name's hold lapses. | |
| 102 | expiresAt: Math.floor((now + CHECKOUT_MS) / 1000), | |
| 103 | }); | |
| 104 | if (!made.ok) { | |
| 105 | dropPending(app.db, id); | |
| 106 | app.log.warn("a checkout could not be made", { provider: provider.name, says: made.error }); | |
| 107 | return err({ field: "form", message: "The payment provider could not be reached; nothing was charged. Try again in a minute" }); | |
| 108 | } | |
| 109 | setSession(app.db, id, made.value.id); | |
| 110 | app.bus.append({ kind: "billing.order_placed", payload: { id, forge: name, plan: plan.key } }); | |
| 111 | return ok({ id, ticket, redirect: made.value.url }); | |
| 112 | } | |
| 113 | ||
| 114 | /** | |
| 115 | * The order is paid: make its forge, once. Called by the webhook and by the | |
| 116 | * return page; only the call whose move out of `pending` took effect goes on, | |
| 117 | * and a later call just reads where the order is. Never throws. | |
| 118 | */ | |
| 119 | export async function settleOrder(app: App, id: string, paid: { customer?: string; subscription?: string } = {}): Promise<Order | undefined> { | |
| 120 | const now = Date.now(); | |
| 121 | const o = getOrder(app.db, id); | |
| 122 | if (o === undefined) return undefined; | |
| 123 | if (!move(app.db, id, "pending", "paid", now, { customerId: paid.customer ?? "", subscriptionId: paid.subscription ?? "" })) return o; | |
| 124 | app.bus.append({ kind: "billing.order_paid", payload: { id, forge: o.name, plan: o.plan } }); | |
| 125 | return makeForge(app, id, "paid"); | |
| 126 | } | |
| 127 | ||
| 128 | /** Make the forge of an order standing in `from` (paid, or failed for a retry). */ | |
| 129 | async function makeForge(app: App, id: string, from: "paid" | "failed" | "spawning"): Promise<Order | undefined> { | |
| 130 | if (!move(app.db, id, from, "spawning", Date.now())) return getOrder(app.db, id); | |
| 131 | const o = getOrder(app.db, id) as Order; | |
| 132 | const fail = (reason: string): Order => { | |
| 133 | move(app.db, id, "spawning", "failed", Date.now(), { reason }); | |
| 134 | app.bus.append({ kind: "billing.order_failed", payload: { id, forge: o.name, plan: o.plan, reason } }); | |
| 135 | app.log.error("a paid order could not be made into a forge", { id, forge: o.name, reason }); | |
| 136 | return getOrder(app.db, id) as Order; | |
| 137 | }; | |
| 138 | try { | |
| 139 | const plan = getPlan(app.db, o.plan); | |
| 140 | if (plan === undefined) return fail(`the ${o.plan} plan is no longer offered`); | |
| 141 | const door = await spawnDoor(systemCtx(app), undefined, { name: o.name, apps: [], crew: [], plan: plan.key, dryRun: false }, { paid: true }); | |
| 142 | if (door.status >= 400) return fail(String(door.body["message"] ?? "the forge could not be made").slice(0, 300)); | |
| 143 | putEntitlement(app.db, { | |
| 144 | forge: o.name, plan: plan.key, status: "active", provider: checkoutOf(app)?.name ?? "none", payer: o.email, | |
| 145 | customerId: o.customerId, subscriptionId: o.subscriptionId, source: checkoutOf(app)?.name ?? "paid", | |
| 146 | }, plan.size, ""); | |
| 147 | move(app.db, id, "spawning", "spawned", Date.now()); | |
| 148 | app.bus.append({ kind: "billing.order_spawned", payload: { id, forge: o.name, plan: plan.key } }); | |
| 149 | return getOrder(app.db, id); | |
| 150 | } catch (error) { | |
| 151 | return fail(`the forge could not be made: ${String(error).slice(0, 200)}`); | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | /** An event from the provider says this session was paid: settle its order, if it is one of ours. */ | |
| 156 | export async function settleSession(app: App, session: { id?: unknown; metadata?: { order?: unknown } | null; customer?: unknown; subscription?: unknown; payment_status?: unknown }): Promise<Order | undefined> { | |
| 157 | if (session.payment_status !== "paid" && session.payment_status !== "no_payment_required") return undefined; | |
| 158 | const id = typeof session.metadata?.order === "string" ? session.metadata.order : undefined; | |
| 159 | const sid = typeof session.id === "string" ? session.id : ""; | |
| 160 | const o = id === undefined ? undefined : getOrder(app.db, id); | |
| 161 | // The session must be the one this order made: a made-up order id on another session settles nothing. | |
| 162 | if (o === undefined || sid === "" || o.sessionId !== sid) return undefined; | |
| 163 | const text = (v: unknown): string => (typeof v === "string" && /^[A-Za-z0-9_]{1,100}$/.test(v) ? v : ""); | |
| 164 | return settleOrder(app, o.id, { customer: text(session.customer), subscription: text(session.subscription) }); | |
| 165 | } | |
| 166 | ||
| 167 | /** The return page's reconcile: a pending order is asked of the provider, and settled if it was paid. */ | |
| 168 | export async function reconcileOrder(app: App, id: string): Promise<Order | undefined> { | |
| 169 | const o = getOrder(app.db, id); | |
| 170 | if (o === undefined) return undefined; | |
| 171 | if (o.state === "spawning" && Date.now() - o.updatedAt > STALE_MS) return makeForge(app, id, "spawning"); | |
| 172 | if (o.state !== "pending" || o.sessionId === "") return o; | |
| 173 | const provider = checkoutOf(app); | |
| 174 | if (provider === undefined) return o; | |
| 175 | const seen = await provider.state(o.sessionId); | |
| 176 | if (!seen.ok || !seen.value.paid) return o; | |
| 177 | return settleOrder(app, id, { customer: seen.value.customer, subscription: seen.value.subscription }); | |
| 178 | } | |
| 179 | ||
| 180 | export type Delivered = | |
| 181 | | { readonly kind: "link"; readonly claimUrl: string; readonly mailed: boolean } | |
| 182 | | { readonly kind: "shown" } | |
| 183 | | { readonly kind: "claimed" } | |
| 184 | | { readonly kind: "failed" }; | |
| 185 | ||
| 186 | const mailOf = (name: string, link: string) => ({ | |
| 187 | subject: `Your forge ${name} is ready`, | |
| 188 | text: ["Your forge is ready.", "", "Open this link to take it over. It works once, for a day:", "", link, "", "If you did not buy a forge, ignore this message."].join("\n"), | |
| 189 | }); | |
| 190 | ||
| 191 | /** Send the link to the order's email when the forge can mail; true when it went. */ | |
| 192 | async function mailLink(app: App, o: Order, link: string): Promise<boolean> { | |
| 193 | const mailer = mailerOf(app, { id: "order" }); | |
| 194 | if (mailer === undefined || !mayMail(app, o.email)) return false; | |
| 195 | const m = mailOf(o.name, link); | |
| 196 | return (await sendCapped(app, mailer, { to: o.email, ...m })) === "sent"; | |
| 197 | } | |
| 198 | ||
| 199 | /** A spawned order's claim link, for the buyer who holds its ticket: made fresh, shown once, mailed when mail is on. */ | |
| 200 | export async function deliverLink(app: App, o: Order): Promise<Delivered> { | |
| 201 | if (o.state !== "spawned") return { kind: "failed" }; | |
| 202 | if (!markShown(app.db, o.id, Date.now())) return { kind: "shown" }; | |
| 203 | const made = await reissueClaimFor(app, o.name); | |
| 204 | if (!made.ok) { | |
| 205 | unmarkShown(app.db, o.id); | |
| 206 | return "reason" in made.error && made.error.reason === "claimed" ? { kind: "claimed" } : { kind: "failed" }; | |
| 207 | } | |
| 208 | return { kind: "link", claimUrl: made.value.claimUrl, mailed: await mailLink(app, o, made.value.claimUrl) }; | |
| 209 | } | |
| 210 | ||
| 211 | /** A buyer who left asks for the link again: it goes to the order's own email and nowhere else. Always the same answer. */ | |
| 212 | export async function resendLink(app: App, nameIn: unknown, emailIn: unknown, now = Date.now()): Promise<"sent" | "no_mail" | "asked"> { | |
| 213 | if (mailerOf(app, { id: "order" }) === undefined) return "no_mail"; | |
| 214 | const name = typeof nameIn === "string" ? nameIn.trim() : ""; | |
| 215 | const email = emailOf(emailIn); | |
| 216 | const o = email === undefined || !isChildName(name) ? undefined : spawnedFor(app.db, name, email); | |
| 217 | // One a day per order, and only to the address it was bought with. | |
| 218 | if (o === undefined || (o.resentAt !== null && now - o.resentAt < RESEND_MS)) return "asked"; | |
| 219 | const made = await reissueClaimFor(app, o.name); | |
| 220 | if (!made.ok) return "asked"; | |
| 221 | markResent(app.db, o.id, now); | |
| 222 | await mailLink(app, o, made.value.claimUrl); | |
| 223 | return "asked"; | |
| 224 | } | |
| 225 | ||
| 226 | export type RetryError = "not_found" | "not_failed"; | |
| 227 | ||
| 228 | /** An admin who is a person retries a failed (or lost) spawn of a paid order. */ | |
| 229 | export async function retryOrder(app: App, actor: User | undefined, id: unknown): Promise<Result<Order, RetryError>> { | |
| 230 | if (actor?.isAdmin !== true || actor.kind !== "user") return err("not_found"); | |
| 231 | const o = typeof id === "string" ? getOrder(app.db, id) : undefined; | |
| 232 | if (o === undefined) return err("not_found"); | |
| 233 | const stale = o.state === "spawning" && Date.now() - o.updatedAt > STALE_MS; | |
| 234 | if (o.state !== "failed" && !stale) return err("not_failed"); | |
| 235 | app.bus.append({ kind: "billing.order_retried", actorId: actor.id, payload: { id: o.id, forge: o.name } }); | |
| 236 | const made = await makeForge(app, o.id, stale ? "spawning" : "failed"); | |
| 237 | return ok(made ?? o); | |
| 238 | } | |
| 239 | ||
| 240 | /** The ticket a cookie carries fits this order. */ | |
| 241 | export const mayView = (app: App, id: string, ticket: string): boolean => ticketFits(app.db, id, ticket); |
src/actions/spawn.ts +15 −2
| @@ -288,7 +288,7 @@ async function make( | ||
| 288 | 288 | if (plan.plan !== undefined && chosen === undefined) |
| 289 | 289 | return err({ kind: "invalid", field: "plan", reason: `${plan.plan} is not a plan this forge offers` }); |
| 290 | 290 | if (chosen !== undefined && chosen.key !== FREE && deps.gift === true) { |
| 291 | // A redeemed gift code (`actions/codes.ts`) is what lends this plan: it asks no admin and no payment. | |
| 291 | // A redeemed gift code (`actions/codes.ts`) or a paid order (`actions/order.ts`) is what lends this plan: it asks no admin. | |
| 292 | 292 | } else if (chosen !== undefined && chosen.key !== FREE) { |
| 293 | 293 | if (owner === undefined) return err({ kind: "refused", reason: "plan", message: `the ${chosen.name} plan is for a signed-in person; sign in first` }); |
| 294 | 294 | if (!owner.isAdmin) return err({ kind: "refused", reason: "plan", message: PAID_PLAN_ADMIN_ONLY(chosen.name) }); |
| @@ -677,6 +677,20 @@ export async function reissueClaim( | ||
| 677 | 677 | const user = requireUser(actor); |
| 678 | 678 | if (!user.ok) return err({ kind: "unauthorized" }); |
| 679 | 679 | if (!user.value.isAdmin) return err({ kind: "forbidden" }); |
| 680 | const made = await reissueClaimFor(app, name); | |
| 681 | if (made.ok) app.bus.append({ kind: "forge.claim_reissued", actorId: user.value.id, payload: { name } }); | |
| 682 | return made; | |
| 683 | } | |
| 684 | ||
| 685 | /** | |
| 686 | * A new claim link for an unclaimed child, with no actor: the caller has | |
| 687 | * already decided who may have it (an admin above; the buyer of the forge in | |
| 688 | * `actions/order.ts`). The old link stops working. | |
| 689 | */ | |
| 690 | export async function reissueClaimFor( | |
| 691 | app: App, | |
| 692 | name: string, | |
| 693 | ): Promise<Result<{ readonly name: string; readonly claimUrl: string }, SpawnError | { readonly kind: "not_found" }>> { | |
| 680 | 694 | const ledger = /^[a-z0-9-]{1,39}$/.test(name) ? await readLedger(app, name) : undefined; |
| 681 | 695 | if (!ledger || !ledger.done) return err({ kind: "not_found" }); |
| 682 | 696 | const child = createApp({ dataDir: childDirOf(app, ledger.bodyId) }); |
| @@ -686,7 +700,6 @@ export async function reissueClaim( | ||
| 686 | 700 | if (hasAdmin(child.db)) |
| 687 | 701 | return err({ kind: "refused", reason: "claimed", message: `${name} is already claimed; ask its owner to invite you` }); |
| 688 | 702 | const token = mintClaim(child.db, spawned.org.id); |
| 689 | app.bus.append({ kind: "forge.claim_reissued", actorId: user.value.id, payload: { name } }); | |
| 690 | 703 | return ok({ name, claimUrl: claimLink(liveChildUrl(app, name), token) }); |
| 691 | 704 | } finally { |
| 692 | 705 | child.close(); |
src/actions/verify.ts +1 −1
| @@ -56,7 +56,7 @@ export function mailConfigured(app: App): boolean { | ||
| 56 | 56 | return MAIL_SECRETS.every((name) => set.has(name)); |
| 57 | 57 | } |
| 58 | 58 | |
| 59 | function mailerOf(app: App, forUser: User): Mailer | undefined { | |
| 59 | export function mailerOf(app: App, forUser: { readonly id: string }): Mailer | undefined { | |
| 60 | 60 | if (app.config.mailer !== undefined) return app.config.mailer; |
| 61 | 61 | const stored = resolveSecrets( |
| 62 | 62 | app.db, |
src/billing/checkout.ts added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | /** | |
| 2 | * The checkout seam (#11138): what the sale needs of a payment provider, | |
| 3 | * and nothing else. `create` makes a hosted checkout for one order and | |
| 4 | * returns where to send the buyer; `state` asks whether a session was paid | |
| 5 | * (the return page reconciles with it when the webhook is late). The | |
| 6 | * provider is Stripe when it is connected (`billing/connection.ts`), else | |
| 7 | * `none`: nothing is sold and the pricing page says so. A test swaps in its | |
| 8 | * own with `useCheckout`. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { App } from "../app.ts"; | |
| 12 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 13 | import { connectionOf, openKey } from "./connection.ts"; | |
| 14 | import { stripeClient, type CheckoutRequest, type CheckoutSession, type CheckoutState } from "./stripe.ts"; | |
| 15 | ||
| 16 | export type { CheckoutRequest, CheckoutSession, CheckoutState }; | |
| 17 | ||
| 18 | export interface CheckoutProvider { | |
| 19 | readonly name: string; | |
| 20 | create(req: CheckoutRequest): Promise<Result<CheckoutSession, string>>; | |
| 21 | state(sessionId: string): Promise<Result<CheckoutState, string>>; | |
| 22 | } | |
| 23 | ||
| 24 | const swapped = new WeakMap<App, CheckoutProvider>(); | |
| 25 | ||
| 26 | /** Use `provider` for this app; for tests. */ | |
| 27 | export function useCheckout(app: App, provider: CheckoutProvider | undefined): void { | |
| 28 | if (provider === undefined) swapped.delete(app); | |
| 29 | else swapped.set(app, provider); | |
| 30 | } | |
| 31 | ||
| 32 | /** The provider this forge sells through now, or undefined when none is connected. */ | |
| 33 | export function checkoutOf(app: App): CheckoutProvider | undefined { | |
| 34 | const test = swapped.get(app); | |
| 35 | if (test !== undefined) return test; | |
| 36 | if (connectionOf(app.db) === undefined) return undefined; | |
| 37 | const open = openKey(app.db, app.config.dataDir, app.bus, "checkout"); | |
| 38 | if (open === undefined) return undefined; | |
| 39 | const client = stripeClient(open.key); | |
| 40 | return { | |
| 41 | name: "stripe", | |
| 42 | async create(req) { | |
| 43 | const r = await client.createCheckout(req); | |
| 44 | return r.ok ? r : err(r.error.message); | |
| 45 | }, | |
| 46 | async state(id) { | |
| 47 | const r = await client.getCheckout(id); | |
| 48 | return r.ok ? ok(r.value) : err(r.error.message); | |
| 49 | }, | |
| 50 | }; | |
| 51 | } | |
| 52 | ||
| 53 | /** Where the operator pays back a buyer: the provider's own dashboard. */ | |
| 54 | export const REFUND_URL = "https://dashboard.stripe.com/payments"; |
src/billing/orders.ts added +160
| @@ -0,0 +1,160 @@ | ||
| 1 | /** | |
| 2 | * The store of orders (#11138): a visitor's purchase of a forge, from the | |
| 3 | * name being held to the forge being made. Plain rows in, plain rows out; | |
| 4 | * the checkout and the spawn are in `actions/order.ts`. | |
| 5 | * | |
| 6 | * States: `pending` (name held for 30 minutes, nothing paid), `paid` | |
| 7 | * (money taken, forge not yet made), `spawning`, `spawned`, `failed` (paid, | |
| 8 | * the forge could not be made: an admin's alarm). A move is one UPDATE that | |
| 9 | * names the state it leaves, so two deliveries of the same payment cannot | |
| 10 | * both make the forge: only the one whose UPDATE changed a row goes on. | |
| 11 | * The buyer's ticket (a cookie) is kept as its sha256; the claim link is | |
| 12 | * never kept. | |
| 13 | */ | |
| 14 | ||
| 15 | import type { Database } from "bun:sqlite"; | |
| 16 | import { sha256 } from "../kernel/hash.ts"; | |
| 17 | ||
| 18 | export type OrderState = "pending" | "paid" | "spawning" | "spawned" | "failed"; | |
| 19 | ||
| 20 | /** The hold outlives the checkout (`CHECKOUT_MS`) so a buyer who pays at the last minute still finds the name theirs. */ | |
| 21 | export const HOLD_MS = 35 * 60_000; | |
| 22 | /** How long the provider's checkout stays payable; Stripe's least is 30 minutes. */ | |
| 23 | export const CHECKOUT_MS = 31 * 60_000; | |
| 24 | /** Most orders waiting for payment at once: a name-hoarder cannot hold the whole namespace. */ | |
| 25 | export const MAX_PENDING = 200; | |
| 26 | /** Most orders one client address may have waiting at once: one stranger cannot fill the shared cap. */ | |
| 27 | export const MAX_PENDING_PER_ADDRESS = 3; | |
| 28 | ||
| 29 | export interface Order { | |
| 30 | readonly id: string; | |
| 31 | readonly name: string; | |
| 32 | readonly plan: string; | |
| 33 | readonly email: string; | |
| 34 | readonly state: OrderState; | |
| 35 | readonly sessionId: string; | |
| 36 | readonly customerId: string; | |
| 37 | readonly subscriptionId: string; | |
| 38 | readonly reason: string; | |
| 39 | readonly createdAt: number; | |
| 40 | readonly holdUntil: number; | |
| 41 | readonly updatedAt: number; | |
| 42 | readonly shownAt: number | null; | |
| 43 | readonly resentAt: number | null; | |
| 44 | } | |
| 45 | ||
| 46 | interface Row { | |
| 47 | id: string; name: string; plan: string; email: string; state: OrderState; ticket_hash: string; session_id: string; | |
| 48 | customer_id: string; subscription_id: string; reason: string; created_at: number; hold_until: number; updated_at: number; | |
| 49 | shown_at: number | null; resent_at: number | null; | |
| 50 | } | |
| 51 | ||
| 52 | const fromRow = (r: Row): Order => ({ | |
| 53 | id: r.id, name: r.name, plan: r.plan, email: r.email, state: r.state, sessionId: r.session_id, customerId: r.customer_id, | |
| 54 | subscriptionId: r.subscription_id, reason: r.reason, createdAt: r.created_at, holdUntil: r.hold_until, updatedAt: r.updated_at, | |
| 55 | shownAt: r.shown_at, resentAt: r.resent_at, | |
| 56 | }); | |
| 57 | ||
| 58 | export const newId = (): string => `ord_${crypto.randomUUID().replace(/-/g, "")}`; | |
| 59 | export const newTicket = (): string => Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString("hex"); | |
| 60 | ||
| 61 | export function getOrder(db: Database, id: string): Order | undefined { | |
| 62 | const r = db.query<Row, [string]>("SELECT * FROM forge_order WHERE id = ?1").get(id); | |
| 63 | return r ? fromRow(r) : undefined; | |
| 64 | } | |
| 65 | ||
| 66 | export function bySession(db: Database, sessionId: string): Order | undefined { | |
| 67 | if (sessionId === "") return undefined; | |
| 68 | const r = db.query<Row, [string]>("SELECT * FROM forge_order WHERE session_id = ?1").get(sessionId); | |
| 69 | return r ? fromRow(r) : undefined; | |
| 70 | } | |
| 71 | ||
| 72 | /** True when the ticket is the one the order was made with. */ | |
| 73 | export function ticketFits(db: Database, id: string, ticket: string): boolean { | |
| 74 | const r = db.query<{ ticket_hash: string }, [string]>("SELECT ticket_hash FROM forge_order WHERE id = ?1").get(id); | |
| 75 | return r !== null && ticket !== "" && r.ticket_hash === sha256(ticket); | |
| 76 | } | |
| 77 | ||
| 78 | /** Whether an order other than a lapsed pending one holds `name` now. */ | |
| 79 | export function nameHeld(db: Database, name: string, now: number): boolean { | |
| 80 | return ( | |
| 81 | db | |
| 82 | .query<{ n: number }, [string, number]>( | |
| 83 | "SELECT COUNT(*) AS n FROM forge_order WHERE name = ?1 AND (state != 'pending' OR hold_until > ?2)", | |
| 84 | ) | |
| 85 | .get(name, now)?.n !== 0 | |
| 86 | ); | |
| 87 | } | |
| 88 | ||
| 89 | /** Orders waiting for payment from one client address (`http/peer.ts` `clientBucket`, never a header). */ | |
| 90 | export const pendingFrom = (db: Database, address: string, now: number): number => | |
| 91 | db.query<{ n: number }, [string, number]>("SELECT COUNT(*) AS n FROM forge_order WHERE state = 'pending' AND hold_until > ?2 AND address = ?1").get(address, now)?.n ?? 0; | |
| 92 | ||
| 93 | export const pendingCount = (db: Database, now: number): number => | |
| 94 | db.query<{ n: number }, [number]>("SELECT COUNT(*) AS n FROM forge_order WHERE state = 'pending' AND hold_until > ?1").get(now)?.n ?? 0; | |
| 95 | ||
| 96 | /** Write a new pending order. The hold is checked and the row written in one transaction. False when the name is held. */ | |
| 97 | export function holdName(db: Database, o: { id: string; name: string; plan: string; email: string; ticket: string; address: string }, now: number): boolean { | |
| 98 | return db.transaction(() => { | |
| 99 | if (nameHeld(db, o.name, now)) return false; | |
| 100 | db.query( | |
| 101 | `INSERT INTO forge_order (id, name, plan, email, state, ticket_hash, created_at, hold_until, updated_at, address) | |
| 102 | VALUES (?1, ?2, ?3, ?4, 'pending', ?5, ?6, ?7, ?6, ?8)`, | |
| 103 | ).run(o.id, o.name, o.plan, o.email, sha256(o.ticket), now, now + HOLD_MS, o.address); | |
| 104 | return true; | |
| 105 | })(); | |
| 106 | } | |
| 107 | ||
| 108 | export function setSession(db: Database, id: string, sessionId: string): void { | |
| 109 | db.query("UPDATE forge_order SET session_id = ?2 WHERE id = ?1").run(id, sessionId); | |
| 110 | } | |
| 111 | ||
| 112 | /** Drop a pending order whose checkout could not be made: its name is free again. */ | |
| 113 | export function dropPending(db: Database, id: string): void { | |
| 114 | db.query("DELETE FROM forge_order WHERE id = ?1 AND state = 'pending'").run(id); | |
| 115 | } | |
| 116 | ||
| 117 | /** Move `from` to `to`; true only for the caller whose statement changed the row. */ | |
| 118 | export function move(db: Database, id: string, from: OrderState, to: OrderState, now: number, extra: { reason?: string; customerId?: string; subscriptionId?: string } = {}): boolean { | |
| 119 | return ( | |
| 120 | db | |
| 121 | .query( | |
| 122 | `UPDATE forge_order SET state = ?3, updated_at = ?4, reason = ?5, | |
| 123 | customer_id = CASE WHEN ?6 = '' THEN customer_id ELSE ?6 END, | |
| 124 | subscription_id = CASE WHEN ?7 = '' THEN subscription_id ELSE ?7 END | |
| 125 | WHERE id = ?1 AND state = ?2`, | |
| 126 | ) | |
| 127 | .run(id, from, to, now, extra.reason ?? "", extra.customerId ?? "", extra.subscriptionId ?? "").changes === 1 | |
| 128 | ); | |
| 129 | } | |
| 130 | ||
| 131 | /** The buyer has been shown the link: true once, for the caller that marks it. */ | |
| 132 | export function markShown(db: Database, id: string, now: number): boolean { | |
| 133 | return db.query("UPDATE forge_order SET shown_at = ?2 WHERE id = ?1 AND shown_at IS NULL").run(id, now).changes === 1; | |
| 134 | } | |
| 135 | ||
| 136 | /** Give the link back: a mint that failed leaves the buyer able to see it on the next visit. */ | |
| 137 | export function unmarkShown(db: Database, id: string): void { | |
| 138 | db.query("UPDATE forge_order SET shown_at = NULL WHERE id = ?1").run(id); | |
| 139 | } | |
| 140 | ||
| 141 | export function markResent(db: Database, id: string, now: number): void { | |
| 142 | db.query("UPDATE forge_order SET resent_at = ?2 WHERE id = ?1").run(id, now); | |
| 143 | } | |
| 144 | ||
| 145 | /** The newest spawned order for a name and email, for a re-send. */ | |
| 146 | export function spawnedFor(db: Database, name: string, email: string): Order | undefined { | |
| 147 | const r = db | |
| 148 | .query<Row, [string, string]>("SELECT * FROM forge_order WHERE name = ?1 AND lower(email) = ?2 AND state = 'spawned' ORDER BY created_at DESC LIMIT 1") | |
| 149 | .get(name, email.toLowerCase()); | |
| 150 | return r ? fromRow(r) : undefined; | |
| 151 | } | |
| 152 | ||
| 153 | /** Orders an admin must look at: paid and not made. Newest first. */ | |
| 154 | export function failedOrders(db: Database): Order[] { | |
| 155 | return db.query<Row, []>("SELECT * FROM forge_order WHERE state = 'failed' ORDER BY updated_at DESC LIMIT 50").all().map(fromRow); | |
| 156 | } | |
| 157 | ||
| 158 | export function recentOrders(db: Database, limit = 20): Order[] { | |
| 159 | return db.query<Row, [number]>("SELECT * FROM forge_order WHERE state != 'pending' ORDER BY updated_at DESC LIMIT ?1").all(limit).map(fromRow); | |
| 160 | } |
src/billing/stripe.ts +57
| @@ -63,7 +63,34 @@ export function formBody(data: { [k: string]: Value }): string { | ||
| 63 | 63 | return parts.join("&"); |
| 64 | 64 | } |
| 65 | 65 | |
| 66 | export interface CheckoutRequest { | |
| 67 | readonly orderId: string; | |
| 68 | readonly forge: string; | |
| 69 | readonly plan: string; | |
| 70 | readonly planName: string; | |
| 71 | readonly priceUsd: number; | |
| 72 | readonly email: string; | |
| 73 | readonly successUrl: string; | |
| 74 | readonly cancelUrl: string; | |
| 75 | /** Unix seconds after which the checkout cannot be paid (Stripe: 30 minutes to 24 hours from now). */ | |
| 76 | readonly expiresAt?: number; | |
| 77 | } | |
| 78 | ||
| 79 | export interface CheckoutSession { | |
| 80 | readonly id: string; | |
| 81 | readonly url: string; | |
| 82 | } | |
| 83 | ||
| 84 | /** What the provider says of a session: paid or not, and who is billed. */ | |
| 85 | export interface CheckoutState { | |
| 86 | readonly paid: boolean; | |
| 87 | readonly customer: string; | |
| 88 | readonly subscription: string; | |
| 89 | } | |
| 90 | ||
| 66 | 91 | export interface StripeClient { |
| 92 | createCheckout(req: CheckoutRequest): Promise<Result<CheckoutSession, StripeError>>; | |
| 93 | getCheckout(sessionId: string): Promise<Result<CheckoutState, StripeError>>; | |
| 67 | 94 | addWebhook(url: string): Promise<Result<{ id: string; secret: string }, StripeError>>; |
| 68 | 95 | removeWebhook(id: string): Promise<Result<true, StripeError>>; |
| 69 | 96 | /** The subscription as Stripe has it now. */ |
| @@ -112,6 +139,36 @@ export function stripeClient(key: string, fetchFn: typeof fetch = fetch): Stripe | ||
| 112 | 139 | const missing = (what: string): StripeError => ({ kind: "rejected", status: 502, message: `Stripe's answer had no ${what}.` }); |
| 113 | 140 | |
| 114 | 141 | return { |
| 142 | async createCheckout(req) { | |
| 143 | const r = await call("POST", "/v1/checkout/sessions", { | |
| 144 | mode: "subscription", | |
| 145 | success_url: req.successUrl, | |
| 146 | cancel_url: req.cancelUrl, | |
| 147 | customer_email: req.email, | |
| 148 | client_reference_id: req.orderId, | |
| 149 | ...(req.expiresAt === undefined ? {} : { expires_at: req.expiresAt }), | |
| 150 | line_items: [{ | |
| 151 | quantity: 1, | |
| 152 | price_data: { | |
| 153 | currency: "usd", | |
| 154 | unit_amount: Math.round(req.priceUsd * 100), | |
| 155 | recurring: { interval: "month" }, | |
| 156 | product_data: { name: `${req.planName} forge` }, | |
| 157 | }, | |
| 158 | }], | |
| 159 | metadata: { order: req.orderId, forge: req.forge, plan: req.plan }, | |
| 160 | }, req.orderId); | |
| 161 | if (!r.ok) return r; | |
| 162 | const id = str(r.value, "id"), url = str(r.value, "url"); | |
| 163 | return id && url ? ok({ id, url }) : err(missing("checkout address")); | |
| 164 | }, | |
| 165 | async getCheckout(sessionId) { | |
| 166 | if (!/^cs_[A-Za-z0-9_]{1,200}$/.test(sessionId)) return err({ kind: "rejected", status: 400, message: "That is not a checkout session id." }); | |
| 167 | const r = await call("GET", `/v1/checkout/sessions/${sessionId}`); | |
| 168 | if (!r.ok) return r; | |
| 169 | const paid = r.value["payment_status"] === "paid" || r.value["payment_status"] === "no_payment_required"; | |
| 170 | return ok({ paid, customer: str(r.value, "customer"), subscription: str(r.value, "subscription") }); | |
| 171 | }, | |
| 115 | 172 | async addWebhook(url) { |
| 116 | 173 | const r = await call("POST", "/v1/webhook_endpoints", { url, enabled_events: [...WEBHOOK_EVENTS] }, |
| 117 | 174 | // fresh per attempt: Stripe replays an answer for a repeated key for 24h, and a reconnect must not get a deleted endpoint back |
src/core/migration-pins/20261111140000-forge-orders.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 8f2099c03e05b76718444c78944bd349fb68145cc46e6bc5fde57f091dc11693 |
src/core/migration-pins/20261111150000-forge-order-address.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | fa5d0fb138f7a201bf62e538c21efe4220855230305b3827fb9a08756b056da1 |
src/core/migrations/20261111140000-forge-orders.ts added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | // Orders (#11138): a visitor's purchase of a forge. One row from the moment the name is held (pending) | |
| 2 | // to the forge being made (spawned) or failing (failed, kept for an admin). The buyer's ticket is kept | |
| 3 | // only as a sha256; the claim link is never kept. Additive. | |
| 4 | export default ` | |
| 5 | CREATE TABLE forge_order ( | |
| 6 | id TEXT PRIMARY KEY, | |
| 7 | name TEXT NOT NULL, | |
| 8 | plan TEXT NOT NULL, | |
| 9 | email TEXT NOT NULL, | |
| 10 | state TEXT NOT NULL, | |
| 11 | ticket_hash TEXT NOT NULL, | |
| 12 | session_id TEXT NOT NULL DEFAULT '', | |
| 13 | customer_id TEXT NOT NULL DEFAULT '', | |
| 14 | subscription_id TEXT NOT NULL DEFAULT '', | |
| 15 | reason TEXT NOT NULL DEFAULT '', | |
| 16 | created_at INTEGER NOT NULL, | |
| 17 | hold_until INTEGER NOT NULL, | |
| 18 | updated_at INTEGER NOT NULL, | |
| 19 | shown_at INTEGER, | |
| 20 | resent_at INTEGER | |
| 21 | ); | |
| 22 | CREATE INDEX forge_order_name ON forge_order (name); | |
| 23 | CREATE INDEX forge_order_state ON forge_order (state); | |
| 24 | `; |
src/core/migrations/20261111150000-forge-order-address.ts added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | // Orders (#11138): the client address an order was placed from, so one address cannot hold more than | |
| 2 | // its share of pending names. Additive; older rows keep the empty address. | |
| 3 | export default ` | |
| 4 | ALTER TABLE forge_order ADD COLUMN address TEXT NOT NULL DEFAULT ''; | |
| 5 | CREATE INDEX forge_order_address ON forge_order (address, state); | |
| 6 | `; |
src/flow/alarm_paid_order.ts added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | /** | |
| 2 | * The "paid, not made" flow alarm (#11138): someone paid for a forge and it | |
| 3 | * could not be made (no room, the name taken in a race). Fires from the | |
| 4 | * moment the order failed until an admin retries it into a forge. Names the | |
| 5 | * oldest, so the one who has waited longest is the one shown. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | ||
| 10 | export interface PaidOrderAlarm { | |
| 11 | readonly fired: boolean; | |
| 12 | readonly since?: string; | |
| 13 | readonly says: string; | |
| 14 | } | |
| 15 | ||
| 16 | export function alarmPaidOrder(db: Database): PaidOrderAlarm { | |
| 17 | const rows = db | |
| 18 | .query<{ name: string; reason: string; updated_at: number }, []>("SELECT name, reason, updated_at FROM forge_order WHERE state = 'failed' ORDER BY updated_at ASC") | |
| 19 | .all(); | |
| 20 | const first = rows[0]; | |
| 21 | if (first === undefined) return { fired: false, says: "Every paid order has its forge." }; | |
| 22 | const more = rows.length > 1 ? ` (and ${rows.length - 1} more)` : ""; | |
| 23 | return { fired: true, since: new Date(first.updated_at).toISOString(), says: `A buyer paid for ${first.name} and it could not be made: ${first.reason}${more}.` }; | |
| 24 | } |
src/flow/alarms.ts +4 −1
| @@ -14,6 +14,7 @@ import { ownRepoPath } from "../kernel/own_repo.ts"; | ||
| 14 | 14 | import { bugClockAlarm } from "./bugs.ts"; |
| 15 | 15 | import { alarmBackup } from "./alarm_backup.ts"; |
| 16 | 16 | import { alarmFromApp } from "./alarm_from_app.ts"; |
| 17 | import { alarmPaidOrder } from "./alarm_paid_order.ts"; | |
| 17 | 18 | import { alarmIdleCrew } from "./alarm_idle_crew.ts"; |
| 18 | 19 | import { noReleaseAlarm } from "./alarm_no_release.ts"; |
| 19 | 20 | import { redMainAlarm } from "./alarm_red_main.ts"; |
| @@ -22,7 +23,7 @@ import { alarmSilent } from "./alarm_silent.ts"; | ||
| 22 | 23 | import { alarmStuck } from "./alarm_stuck.ts"; |
| 23 | 24 | |
| 24 | 25 | export interface FiredAlarm { |
| 25 | readonly key: "landing_stopped" | "red_main" | "stuck" | "idle_crew" | "no_release" | "bug_clock" | "silent" | "repeat" | "from_app" | "unreachable" | "backup"; | |
| 26 | readonly key: "landing_stopped" | "red_main" | "stuck" | "idle_crew" | "no_release" | "bug_clock" | "silent" | "repeat" | "from_app" | "unreachable" | "backup" | "paid_order"; | |
| 26 | 27 | readonly says: string; |
| 27 | 28 | readonly since: string; |
| 28 | 29 | /** A same-origin path to the item the alarm names. */ |
| @@ -62,6 +63,8 @@ export function firedAlarms( | ||
| 62 | 63 | if (fromApp.fired) out.push({ key: "from_app", says: fromApp.says, since: fromApp.since!, href: `/${fromApp.repo}/issues/${fromApp.number}` }); |
| 63 | 64 | const backup = opts.dataDir === null ? undefined : alarmBackup(opts.dataDir, now); |
| 64 | 65 | if (backup?.fired) out.push({ key: "backup", says: backup.says, since: backup.since!, href: "/flow" }); |
| 66 | const paidOrder = alarmPaidOrder(db); | |
| 67 | if (paidOrder.fired) out.push({ key: "paid_order", says: paidOrder.says, since: paidOrder.since!, href: "/settings/billing" }); | |
| 65 | 68 | const idle = alarmIdleCrew(db, now, { ceilingUsd: opts.ceilingUsd ?? null }); |
| 66 | 69 | if (idle.fired) out.push({ key: "idle_crew", says: idle.says, since: idle.since!, href: "/boards" }); |
| 67 | 70 | } |
src/http/server.ts +6 −2
| @@ -35,7 +35,6 @@ import { maintenanceDuty, maintenancePage, maintenancePrepare, maintenanceResume | ||
| 35 | 35 | import { maintenanceBudget } from "../web/pages/maintenance_budgets.ts"; |
| 36 | 36 | import { nightlyOutputPage, resiliencePage } from "../web/pages/resilience.ts"; |
| 37 | 37 | import { agentStartSet, billingLinksSet, limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts"; |
| 38 | import { pricingPage } from "../web/pricing.ts"; | |
| 39 | 38 | import { plansSet } from "../web/settings/plans.ts"; |
| 40 | 39 | import { allFlowsPage } from "../web/pages/flows-all.ts"; |
| 41 | 40 | import { measureSubmit } from "./measure.ts"; |
| @@ -281,6 +280,7 @@ import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeRemoveConfirm, fo | ||
| 281 | 280 | forgeOwnCodeSubmit, forgesPage } from "../web/pages/forges.ts"; |
| 282 | 281 | import { forgeItSubmit } from "../web/pages/forge-it.ts"; |
| 283 | 282 | import { funnelSubmit } from "./funnel.ts"; |
| 283 | import { pricingDone, pricingOrder, pricingPage, resendPage, resendSubmit } from "../web/pages/pricing.ts"; | |
| 284 | 284 | import { forgeWaitingPage } from "../web/pages/forge-waiting.ts"; |
| 285 | 285 | import { startForgeSubmit } from "../web/pages/start-forge.ts"; |
| 286 | 286 | import { newForgePage, newForgeSubmit } from "../web/pages/new-forge.ts"; |
| @@ -858,6 +858,11 @@ export function buildRouter(): Router<Handler> { | ||
| 858 | 858 | router.add("GET", "/forges/:name/remove", forgeRemoveConfirm); |
| 859 | 859 | router.add("POST", "/forges/:name/remove", forgeRemoveSubmit); |
| 860 | 860 | router.add("GET", "/forges/:name/ready", forgeReady); |
| 861 | router.add("GET", "/pricing", pricingPage); | |
| 862 | router.add("POST", "/pricing/order", pricingOrder); | |
| 863 | router.add("GET", "/pricing/done", pricingDone); | |
| 864 | router.add("GET", "/pricing/resend", resendPage); | |
| 865 | router.add("POST", "/pricing/resend", resendSubmit); | |
| 861 | 866 | router.add("GET", "/forges/:name/waiting", forgeWaitingPage); |
| 862 | 867 | router.add("GET", "/forges/new", newForgePage); |
| 863 | 868 | router.add("POST", "/forges/new", newForgeSubmit); |
| @@ -1021,7 +1026,6 @@ export function buildRouter(): Router<Handler> { | ||
| 1021 | 1026 | router.add("GET", "/live/moving", liveMovingFragment); |
| 1022 | 1027 | router.add("GET", "/live/chips", liveChipsFragment); |
| 1023 | 1028 | router.add("GET", "/feed", feedPage); |
| 1024 | router.add("GET", "/pricing", pricingPage); | |
| 1025 | 1029 | // The home's late regions, each drawn alone (#7660). |
| 1026 | 1030 | for (const name of HOME_REGIONS) router.add("GET", `/live/home/${name}`, (ctx) => liveHomeFragment(ctx, name)); |
| 1027 | 1031 | // Whether a hostname is an app's verified domain — what Caddy's |
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 } from "../web/pages/billing.ts"; | |
| 21 | import { billingPage, billingGrant, billingSetLinks, 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"; |
| @@ -215,6 +215,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 215 | 215 | router.add("POST", "/settings/billing/stripe/connect", stripeConnect); |
| 216 | 216 | router.add("POST", "/settings/billing/stripe/secret", stripeSecret); |
| 217 | 217 | router.add("POST", "/settings/billing/stripe/disconnect", stripeDisconnect); |
| 218 | router.add("POST", "/settings/billing/orders/retry", orderRetry); | |
| 218 | 219 | router.add("POST", "/settings/billing/codes", billingMakeCode); |
| 219 | 220 | router.add("POST", "/settings/billing/codes/revoke", billingRevokeCode); |
| 220 | 221 | router.add("GET", "/settings/flags", flagsPage); |
src/http/stripe_hook.ts +9
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | 15 | import type { App } from "../app.ts"; |
| 16 | import { settleSession } from "../actions/order.ts"; | |
| 16 | 17 | import { bindCheckout } from "../billing/checkout_completed.ts"; |
| 17 | 18 | import { openWebhookSecret } from "../billing/connection.ts"; |
| 18 | 19 | import { syncSubscription } from "../billing/sync.ts"; |
| @@ -119,6 +120,14 @@ export async function stripeHook(ctx: Ctx): Promise<Response> { | ||
| 119 | 120 | if (stored.changes === 1 && type === "checkout.session.completed") bindCheckout(app, event); |
| 120 | 121 | return stored.changes === 1; |
| 121 | 122 | })(); |
| 123 | // A paid checkout that is one of our orders makes its forge (once, whoever sees the payment first), then binds it to the customer. | |
| 124 | if (fresh && type === "checkout.session.completed") { | |
| 125 | const session = (event as { data?: { object?: unknown } }).data?.object; | |
| 126 | if (session !== null && typeof session === "object") { | |
| 127 | const settled = await settleSession(app, session as Parameters<typeof settleSession>[1]); | |
| 128 | if (settled?.state === "spawned") bindCheckout(app, event); | |
| 129 | } | |
| 130 | } | |
| 122 | 131 | if (fresh && (type.startsWith("customer.subscription.") || type.startsWith("invoice."))) { |
| 123 | 132 | try { |
| 124 | 133 | await syncSubscription(app, subscriptionIdOf(event)); |
src/spawn/door.ts +10 −7
| @@ -181,12 +181,15 @@ export async function spawnDoor( | ||
| 181 | 181 | ctx: Ctx, |
| 182 | 182 | user: User | undefined, |
| 183 | 183 | raw: Record<string, unknown>, |
| 184 | /** `paid`: the plan was bought (`actions/order.ts`), so a paid plan needs no admin and a stranger's rate limits do not apply. */ | |
| 185 | opts: { readonly paid?: boolean } = {}, | |
| 184 | 186 | ): Promise<Door> { |
| 187 | const paid = opts.paid === true; | |
| 185 | 188 | const anon = user === undefined; |
| 186 | 189 | if (!anon && user.kind !== "user") return spawnWords({ kind: "forbidden" }); |
| 187 | 190 | // A plan is free of children but not of work — it reads every catalog |
| 188 | 191 | // app it names — so a stranger's are counted per address and per forge. |
| 189 | if (anon) { | |
| 192 | if (anon && !paid) { | |
| 190 | 193 | const verdict = takeDoor(ctx.app, "plan", ctx.request); |
| 191 | 194 | if (!verdict.allowed) |
| 192 | 195 | return { |
| @@ -282,8 +285,8 @@ export async function spawnDoor( | ||
| 282 | 285 | if (raw["plan"] !== undefined && raw["plan"] !== null && raw["plan"] !== "") { |
| 283 | 286 | const chosen = typeof raw["plan"] === "string" ? getPlan(db, raw["plan"]) : undefined; |
| 284 | 287 | if (chosen === undefined) return invalid("plan", `must be one of ${getPlans(db).map((p) => p.key).join(", ")}`); |
| 285 | if (anon && chosen.key !== FREE && giftIn === undefined) return invalid("plan", `the ${chosen.name} plan is for a signed-in person; sign in first`); | |
| 286 | if (chosen.key !== FREE && user?.isAdmin !== true && giftIn === undefined) return invalid("plan", PAID_PLAN_ADMIN_ONLY(chosen.name)); | |
| 288 | if (anon && chosen.key !== FREE && giftIn === undefined && !paid) return invalid("plan", `the ${chosen.name} plan is for a signed-in person; sign in first`); | |
| 289 | if (chosen.key !== FREE && user?.isAdmin !== true && giftIn === undefined && !paid) return invalid("plan", PAID_PLAN_ADMIN_ONLY(chosen.name)); | |
| 287 | 290 | if (size !== undefined && size !== null && size !== "" && size !== chosen.size) |
| 288 | 291 | return invalid("size", `the ${chosen.name} plan is a ${chosen.size} forge; leave size out or send ${chosen.size}`); |
| 289 | 292 | planKey = chosen.key; |
| @@ -325,7 +328,7 @@ export async function spawnDoor( | ||
| 325 | 328 | }; |
| 326 | 329 | } |
| 327 | 330 | // A stranger may start only a few children a day; the plan above is free. |
| 328 | if (anon) { | |
| 331 | if (anon && !paid) { | |
| 329 | 332 | const limit = checkAnonSpawn(ctx.app.db, ctx.request); |
| 330 | 333 | if (!limit.allowed) |
| 331 | 334 | return { |
| @@ -349,7 +352,7 @@ export async function spawnDoor( | ||
| 349 | 352 | let made: Awaited<ReturnType<typeof spawnForge>>; |
| 350 | 353 | try { |
| 351 | 354 | made = await spawnForge(ctx.app, user, input, source, { |
| 352 | ...(gift === undefined ? {} : { gift: true }), | |
| 355 | ...(gift === undefined && !paid ? {} : { gift: true }), | |
| 353 | 356 | placement: placementFor(ctx.app), |
| 354 | 357 | request: ctx.request, |
| 355 | 358 | url: ctx.url, |
| @@ -364,9 +367,9 @@ export async function spawnDoor( | ||
| 364 | 367 | if (gift !== undefined && !settled) unclaimGift(ctx.app, gift); |
| 365 | 368 | } |
| 366 | 369 | if (!made.ok) return spawnWords(made.error); |
| 367 | if (anon && !made.value.existed) noteAnonSpawn(ctx.app.db, ctx.request); | |
| 370 | if (anon && !paid && !made.value.existed) noteAnonSpawn(ctx.app.db, ctx.request); | |
| 368 | 371 | // The visitor who started (or was given) this forge becomes someone, on the host's side (#11265). |
| 369 | if (!made.value.existed) linkRequestVisitor(ctx.app, ctx.request, made.value.name, gift === undefined ? "started" : "paid"); | |
| 372 | if (!made.value.existed && !paid) linkRequestVisitor(ctx.app, ctx.request, made.value.name, gift === undefined ? "started" : "paid"); | |
| 370 | 373 | // Its apps come up now, so the first visit after a claim finds them awake. |
| 371 | 374 | if (!made.value.existed) { |
| 372 | 375 | void bootHostedChild(ctx.app, made.value.name); |
src/web/layout.ts +1
| @@ -732,6 +732,7 @@ function footer(ctx: Ctx, own?: Html): Html { | ||
| 732 | 732 | >`} |
| 733 | 733 | <span class="spacer"></span> |
| 734 | 734 | <a href="/docs">Docs</a> |
| 735 | <a href="/pricing">Pricing</a> | |
| 735 | 736 | <a href="/changelog">Changelog</a> |
| 736 | 737 | <a href="/api">API</a> |
| 737 | 738 | ${ctx.auth.user !== undefined && ctx.auth.user.kind !== "org" |
src/web/pages/billing.ts +28 −1
| @@ -7,6 +7,9 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { ledBlock } from "./visitors.ts"; |
| 9 | 9 | import { BILLING_WORDS, STRIPE_WORDS, connectStripe, disconnectStripe, grantPlan, readBilling, saveSigningSecret, type BillingError, type StripeWords } from "../../actions/billing.ts"; |
| 10 | import { retryOrder } from "../../actions/order.ts"; | |
| 11 | import { REFUND_URL } from "../../billing/checkout.ts"; | |
| 12 | import { failedOrders } from "../../billing/orders.ts"; | |
| 10 | 13 | import { CODE_ERRORS, makeCode, readCodes, revoke, type CodeError } from "../../actions/codes.ts"; |
| 11 | 14 | import { publicOrigin } from "../../core/public_origin.ts"; |
| 12 | 15 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| @@ -186,7 +189,21 @@ function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = { | ||
| 186 | 189 | ${btn("Connect")} |
| 187 | 190 | </form>`} |
| 188 | 191 | </section>`; |
| 189 | const body = html`${stripe}<section class="settings-block"> | |
| 192 | const failed = failedOrders(ctx.app.db); | |
| 193 | const orders = failed.length === 0 ? null : html`<section class="settings-block" id="orders" data-orders> | |
| 194 | <h2>Paid, not made</h2> | |
| 195 | <p class="muted small">These buyers paid and their forge could not be made. Retry the spawn, or pay them back at the provider.</p> | |
| 196 | <ul class="list setting-list">${failed.map((o) => html`<li class="row"> | |
| 197 | <form class="row-fields" method="post" action="${PATH}/orders/retry"> | |
| 198 | <input type="hidden" name="id" value="${o.id}" /> | |
| 199 | <span class="mono">${o.name}</span> | |
| 200 | <span class="muted small">${o.plan} · ${o.email} · ${o.reason}</span> | |
| 201 | ${btn("Retry")} | |
| 202 | <a href="${REFUND_URL}" rel="noopener noreferrer" target="_blank">Refund at provider</a> | |
| 203 | </form> | |
| 204 | </li>`)}</ul> | |
| 205 | </section>`; | |
| 206 | const body = html`${stripe}${orders}<section class="settings-block"> | |
| 190 | 207 | <h2>Plans</h2> |
| 191 | 208 | <p class="muted small">Choose the plan each forge is on.</p> |
| 192 | 209 | ${rows.length === 0 |
| @@ -271,3 +288,13 @@ export async function stripeDisconnect(ctx: Ctx): Promise<Response> { | ||
| 271 | 288 | const r = await disconnectStripe(ctx.app, ctx.auth.user); |
| 272 | 289 | return back(ctx, r.ok ? { ok: true, message: "Stripe is disconnected." } : r); |
| 273 | 290 | } |
| 291 | ||
| 292 | // POST /settings/billing/orders/retry — an admin who is a person retries a paid order whose forge was not made. | |
| 293 | export async function orderRetry(ctx: Ctx): Promise<Response> { | |
| 294 | if (!isPerson(ctx)) return notFoundPage(ctx); | |
| 295 | const form = await readForm(ctx, "admin"); | |
| 296 | if (form instanceof Response) return form; | |
| 297 | const r = await retryOrder(ctx.app, ctx.auth.user, field(form, "id")); | |
| 298 | if (!r.ok) return r.error === "not_found" ? notFoundPage(ctx) : flashTo(ctx, PATH, { tone: "danger", message: "That order is not waiting for a retry." }); | |
| 299 | return flashTo(ctx, PATH, r.value.state === "spawned" ? { message: `${r.value.name} is made.` } : { tone: "danger", message: `${r.value.name} still could not be made: ${r.value.reason}.` }); | |
| 300 | } |
src/web/pages/pricing.ts added +202
| @@ -0,0 +1,202 @@ | ||
| 1 | /** | |
| 2 | * `/pricing` (#11138): the sale. A visitor with no account picks a plan, names | |
| 3 | * the forge, gives an email and pays at the provider's checkout; a minute | |
| 4 | * later `/pricing/done` shows the claim link, once. The free plan goes on to | |
| 5 | * `/forges/new`. With no provider connected the paid plans show their size and | |
| 6 | * say "ask the operator". The rules are in `actions/order.ts`. | |
| 7 | */ | |
| 8 | ||
| 9 | import { deliverLink, mayView, NO_PROVIDER, placeOrder, reconcileOrder, resendLink, type Refusal } from "../../actions/order.ts"; | |
| 10 | import { checkoutOf } from "../../billing/checkout.ts"; | |
| 11 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 12 | import { cookieValue, type Ctx } from "../../http/context.ts"; | |
| 13 | import { secureFor } from "../../http/cookie_secure.ts"; | |
| 14 | import { clientBucket } from "../../http/peer.ts"; | |
| 15 | import { takeDoor } from "../../http/strangers.ts"; | |
| 16 | import { FREE, getPlans, type Plan } from "../../settings/plans.ts"; | |
| 17 | import { sizeWords } from "../../settings/billing.ts"; | |
| 18 | import { childHost } from "../../spawn/door.ts"; | |
| 19 | import { notFoundPage, pageHead } from "../chrome.ts"; | |
| 20 | import { field, readForm } from "../form.ts"; | |
| 21 | import { html, type Html } from "../html.ts"; | |
| 22 | import { page, redirect } from "../layout.ts"; | |
| 23 | import { btn, field as formField, row } from "../ui.ts"; | |
| 24 | import { claimLinkBody } from "./new-forge.ts"; | |
| 25 | ||
| 26 | const COOKIE = "forge_order"; | |
| 27 | const REFRESH_JS = "setTimeout(function(){location.reload()},3000);"; | |
| 28 | ||
| 29 | interface Values { | |
| 30 | readonly name: string; | |
| 31 | readonly email: string; | |
| 32 | } | |
| 33 | ||
| 34 | const isBuyer = (ctx: Ctx): boolean => ctx.auth.user === undefined || ctx.auth.user.kind === "user"; | |
| 35 | ||
| 36 | function plansView(ctx: Ctx, values: Values, errors: Partial<Record<Refusal["field"], string>>): Html { | |
| 37 | const selling = checkoutOf(ctx.app) !== undefined; | |
| 38 | const plans = getPlans(ctx.app.db); | |
| 39 | const rows = plans.map((p: Plan) => { | |
| 40 | const free = p.key === FREE; | |
| 41 | const paid = !free && selling; | |
| 42 | return row({ | |
| 43 | title: html`${p.name}`, | |
| 44 | meta: html`${sizeWords(p.size)}. ${p.says}${p.creditUsd > 0 && selling ? html` $${p.creditUsd} of crew credit each month.` : null}`, | |
| 45 | trail: free | |
| 46 | ? html`<span class="muted">Free</span> ${btn("Start free", { name: "plan", value: p.key })}` | |
| 47 | : paid | |
| 48 | ? html`<span>$${p.priceUsd} a month</span> ${btn(`Get ${p.name}`, { name: "plan", value: p.key, variant: "primary" })}` | |
| 49 | : html`<span class="muted">Ask the operator</span>`, | |
| 50 | data: { plan: p.key }, | |
| 51 | }); | |
| 52 | }); | |
| 53 | return html`<div class="narrow-wide" id="pricing" data-pricing> | |
| 54 | ${pageHead("Get your own forge", { sub: selling ? "Pick a plan, name it, pay. Your forge is ready a minute later." : "Pick a size and name it." })} | |
| 55 | ${errors["form"] ? html`<p class="error" role="alert">${errors["form"]}</p>` : null} | |
| 56 | <form method="post" action="/pricing/order" class="stack" id="order"> | |
| 57 | ${formField({ | |
| 58 | label: "Name", | |
| 59 | name: "name", | |
| 60 | value: values.name, | |
| 61 | required: true, | |
| 62 | hint: `Its address: <name>--${childHost(ctx.app)}.`, | |
| 63 | ...(errors["name"] ? { error: errors["name"] } : {}), | |
| 64 | })} | |
| 65 | ${selling | |
| 66 | ? formField({ | |
| 67 | label: "Email", | |
| 68 | name: "email", | |
| 69 | type: "email", | |
| 70 | value: values.email, | |
| 71 | autocomplete: "email", | |
| 72 | hint: "The link to open your forge is shown after payment and sent here.", | |
| 73 | ...(errors["email"] ? { error: errors["email"] } : {}), | |
| 74 | }) | |
| 75 | : null} | |
| 76 | <ul class="list setting-list">${rows}</ul> | |
| 77 | </form> | |
| 78 | <p class="muted small">Paid already? <a href="/pricing/done">Open your order</a> · <a href="/pricing/resend">Lost your link?</a></p> | |
| 79 | </div>`; | |
| 80 | } | |
| 81 | ||
| 82 | // GET /pricing — public; agents are refused as buyers. | |
| 83 | export function pricingPage(ctx: Ctx): Response { | |
| 84 | if (!isBuyer(ctx)) return notFoundPage(ctx); | |
| 85 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 86 | const rendering = performance.now(); | |
| 87 | const response = page({ ctx, title: "Get your own forge", body: plansView(ctx, { name: (ctx.url.searchParams.get("name") ?? "").slice(0, 38), email: "" }, {}) }); | |
| 88 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 89 | return response; | |
| 90 | } | |
| 91 | ||
| 92 | const cookieFor = (ctx: Ctx, value: string, maxAge: number): string => | |
| 93 | `${COOKIE}=${value}; Path=/pricing; Max-Age=${maxAge}; SameSite=Lax; HttpOnly${secureFor(ctx.url, ctx.app.config.url) ? "; Secure" : ""}`; | |
| 94 | ||
| 95 | // POST /pricing/order — free goes on to the free path; a paid plan holds the name and goes to checkout. | |
| 96 | export async function pricingOrder(ctx: Ctx): Promise<Response> { | |
| 97 | if (!isBuyer(ctx)) return notFoundPage(ctx); | |
| 98 | const form = await readForm(ctx, ctx.auth.user === undefined ? undefined : "admin"); | |
| 99 | if (form instanceof Response) return form; | |
| 100 | const values: Values = { name: field(form, "name").trim().slice(0, 60), email: field(form, "email").trim().slice(0, 254) }; | |
| 101 | const plan = field(form, "plan"); | |
| 102 | if (plan === FREE) return redirect(`/forges/new?name=${encodeURIComponent(values.name.slice(0, 38))}`); | |
| 103 | const verdict = takeDoor(ctx.app, "plan", ctx.request); | |
| 104 | if (!verdict.allowed) return page({ ctx, title: "Get your own forge", body: plansView(ctx, values, { form: `Too many tries from here; try again in ${verdict.retryAfter}s` }) }, 429); | |
| 105 | const placed = await placeOrder(ctx.app, { name: values.name, plan, email: values.email, address: clientBucket(ctx.request) }, ctx.url); | |
| 106 | if (!placed.ok) { | |
| 107 | const status = placed.error.message === NO_PROVIDER ? 409 : 422; | |
| 108 | return page({ ctx, title: "Get your own forge", body: plansView(ctx, values, { [placed.error.field]: `${placed.error.message}.` }) }, status); | |
| 109 | } | |
| 110 | const res = redirect(placed.value.redirect, { cookie: cookieFor(ctx, `${placed.value.id}.${placed.value.ticket}`, 2 * 86_400) }); | |
| 111 | res.headers.set("cache-control", "no-store"); | |
| 112 | return res; | |
| 113 | } | |
| 114 | ||
| 115 | /** The order the cookie names, when the address names the same one. */ | |
| 116 | function mine(ctx: Ctx): string | undefined { | |
| 117 | const id = ctx.url.searchParams.get("order") ?? ""; | |
| 118 | const [cid, ticket] = (cookieValue(ctx.request, COOKIE) ?? "").split("."); | |
| 119 | if (cid !== id || ticket === undefined || !/^ord_[0-9a-f]{32}$/.test(id) || !mayView(ctx.app, id, ticket)) return undefined; | |
| 120 | return id; | |
| 121 | } | |
| 122 | ||
| 123 | const waiting = (ctx: Ctx, says: string): Response => { | |
| 124 | const res = page({ | |
| 125 | ctx, | |
| 126 | title: "Making your forge", | |
| 127 | scriptJs: REFRESH_JS, | |
| 128 | body: html`<div class="narrow stack" data-order="waiting">${pageHead("Making your forge")}<p role="status">${says}</p><p class="small"><a href="${ctx.url.pathname}${ctx.url.search}">Check now</a></p></div>`, | |
| 129 | }); | |
| 130 | res.headers.set("cache-control", "no-store"); | |
| 131 | return res; | |
| 132 | }; | |
| 133 | ||
| 134 | // GET /pricing/done?order= — reconciles the order with the provider, waits for the forge, shows the link once. | |
| 135 | export async function pricingDone(ctx: Ctx): Promise<Response> { | |
| 136 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 137 | const rendering = performance.now(); | |
| 138 | const response = await doneView(ctx); | |
| 139 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 140 | return response; | |
| 141 | } | |
| 142 | ||
| 143 | async function doneView(ctx: Ctx): Promise<Response> { | |
| 144 | if (!isBuyer(ctx)) return notFoundPage(ctx); | |
| 145 | const id = mine(ctx); | |
| 146 | if (id === undefined) return notFoundPage(ctx); | |
| 147 | const o = await reconcileOrder(ctx.app, id); | |
| 148 | if (o === undefined) return notFoundPage(ctx); | |
| 149 | const plain = (title: string, body: Html, status = 200): Response => { | |
| 150 | const res = page({ ctx, title, body: html`<div class="narrow-wide" data-order="${o.state}">${pageHead(title)}<section class="settings-block">${body}</section></div>` }, status); | |
| 151 | res.headers.set("cache-control", "no-store"); | |
| 152 | return res; | |
| 153 | }; | |
| 154 | if (o.state === "pending") return waiting(ctx, "Waiting for the payment to arrive. This page updates by itself."); | |
| 155 | if (o.state === "paid" || o.state === "spawning") return waiting(ctx, `Paid. Making ${o.name} now; the link to open it appears here.`); | |
| 156 | if (o.state === "failed") | |
| 157 | return plain(`${o.name} is paid but not made yet`, html`<p>Your payment went through, but the forge could not be made just now: ${o.reason}.</p> | |
| 158 | <p>The operator has been told and will make it or pay you back. Keep this page's address: <code class="mono">${o.id}</code>.</p>`, 200); | |
| 159 | const given = await deliverLink(ctx.app, o); | |
| 160 | if (given.kind === "link") | |
| 161 | return plain(`${o.name} is ready`, html`${claimLinkBody(given.claimUrl, false)}${given.mailed ? html`<p class="small mt">It was also sent to ${o.email}.</p>` : null}`); | |
| 162 | if (given.kind === "claimed") return plain(`${o.name} is taken over`, html`<p>The link was used; sign in at <a href="${`https://${o.name}--${childHost(ctx.app)}`}">your forge</a>.</p>`); | |
| 163 | return plain(`${o.name} is ready`, html`<p>The link was shown already, once.</p><p class="mt">${btn("Send it again by email", { href: "/pricing/resend", size: "sm" })}</p>`); | |
| 164 | } | |
| 165 | ||
| 166 | function resendView(ctx: Ctx, says: string | undefined, values: Values): Html { | |
| 167 | return html`<div class="narrow stack" data-resend> | |
| 168 | ${pageHead("Lost your link?")} | |
| 169 | ${says === undefined ? null : html`<p role="status">${says}</p>`} | |
| 170 | <form method="post" action="/pricing/resend" class="stack"> | |
| 171 | ${formField({ label: "Name of your forge", name: "name", value: values.name, required: true })} | |
| 172 | ${formField({ label: "Email you bought with", name: "email", type: "email", value: values.email, required: true, hint: "A new link goes there, and the old one stops working." })} | |
| 173 | ${btn("Send the link", { variant: "primary" })} | |
| 174 | </form> | |
| 175 | </div>`; | |
| 176 | } | |
| 177 | ||
| 178 | // GET /pricing/resend | |
| 179 | export function resendPage(ctx: Ctx): Response { | |
| 180 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 181 | if (!isBuyer(ctx)) return notFoundPage(ctx); | |
| 182 | const rendering = performance.now(); | |
| 183 | const response = page({ ctx, title: "Lost your link?", body: resendView(ctx, undefined, { name: "", email: "" }) }); | |
| 184 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 185 | return response; | |
| 186 | } | |
| 187 | ||
| 188 | // POST /pricing/resend — the link goes to the order's own email; the answer is the same whether or not an order matched. | |
| 189 | export async function resendSubmit(ctx: Ctx): Promise<Response> { | |
| 190 | if (!isBuyer(ctx)) return notFoundPage(ctx); | |
| 191 | const form = await readForm(ctx, ctx.auth.user === undefined ? undefined : "admin"); | |
| 192 | if (form instanceof Response) return form; | |
| 193 | const values: Values = { name: field(form, "name").trim().slice(0, 60), email: field(form, "email").trim().slice(0, 254) }; | |
| 194 | const verdict = takeDoor(ctx.app, "plan", ctx.request); | |
| 195 | if (!verdict.allowed) return page({ ctx, title: "Lost your link?", body: resendView(ctx, `Too many tries from here; try again in ${verdict.retryAfter}s.`, values) }, 429); | |
| 196 | const out = await resendLink(ctx.app, values.name, values.email); | |
| 197 | const says = out === "no_mail" ? "This forge cannot send email. Ask the operator for a new link." : "If that order exists, the link is on its way to the email it was bought with."; | |
| 198 | const res = page({ ctx, title: "Lost your link?", body: resendView(ctx, says, { name: "", email: "" }) }); | |
| 199 | res.headers.set("cache-control", "no-store"); | |
| 200 | return res; | |
| 201 | } | |
| 202 |
src/web/pricing.ts deleted −29
| @@ -1,29 +0,0 @@ | ||
| 1 | /** | |
| 2 | * GET /pricing: the plans on a page of their own, so a line that says "pick a | |
| 3 | * plan" (the first screen of a forge whose crew has no credit, #11240) has | |
| 4 | * somewhere to point. The words are the home's (`plans_row.ts`); the terminal's | |
| 5 | * door is the palette's "Get your own forge". | |
| 6 | */ | |
| 7 | ||
| 8 | import { notePhase, takeSessionTime, timePhase } from "../core/route_timing.ts"; | |
| 9 | import type { Ctx } from "../http/context.ts"; | |
| 10 | import { GET_A_FORGE_PATH } from "../billing/plan_words.ts"; | |
| 11 | import { getDoorSettings } from "../settings/door.ts"; | |
| 12 | import { publicPlans } from "../settings/plans.ts"; | |
| 13 | import { freePlanName } from "./funnel_door.ts"; | |
| 14 | import { html } from "./html.ts"; | |
| 15 | import { page } from "./layout.ts"; | |
| 16 | import { plansRow } from "./plans_row.ts"; | |
| 17 | import { btn } from "./ui.ts"; | |
| 18 | ||
| 19 | export function pricingPage(ctx: Ctx): Response { | |
| 20 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 21 | const { plans, links } = getDoorSettings(ctx.app.db); | |
| 22 | const body = html`<section id="pricing" class="stack"> | |
| 23 | <h1>Plans</h1> | |
| 24 | <p class="muted">Each plan comes with crew credit. Or add your own model key and the crew builds on it.</p> | |
| 25 | ${plansRow(publicPlans(plans), links)} | |
| 26 | <div class="bar" data-plan="${freePlanName(plans)}">${btn("Get a forge", { href: GET_A_FORGE_PATH, variant: "primary" })}</div> | |
| 27 | </section>`; | |
| 28 | return timePhase(ctx.request, "render", () => page({ ctx, title: "Plans", body })); | |
| 29 | } |
test/forge-looks.test.ts +1 −1
| @@ -20,6 +20,7 @@ quietLogs(); | ||
| 20 | 20 | |
| 21 | 21 | /** Top-level routes that are not pages, or are not worth a picture, and why. */ |
| 22 | 22 | const NOT_A_PAGE: Record<string, string> = { |
| 23 | "/pricing": "no picture: the cap of 24 looks is spent; the page is covered by test/pricing-order.test.ts", | |
| 23 | 24 | "/audit": "no picture: its slot went to /pinned; /audit.csv is its download, covered by test/audit.test.ts", |
| 24 | 25 | "/deploy-to": "a form for a forge admin that fills from its link; covered by test/pinned-page.test.ts", |
| 25 | 26 | "/settings": "a form of fields; its slot went to the open help of /boards and /settings/crew (#11170); covered by test/account-help.test.ts", |
| @@ -45,7 +46,6 @@ const NOT_A_PAGE: Record<string, string> = { | ||
| 45 | 46 | "/opensearch.xml": "the browser search description, XML", |
| 46 | 47 | "/sw.js": "the service worker script", |
| 47 | 48 | "/audit.csv": "a CSV download of /audit", |
| 48 | "/pricing": "no picture: the cap of 24 looks is spent; the page is covered by test/next-steps-card.test.ts", | |
| 49 | 49 | "/maintenance": "no picture: the cap of 24 looks is spent; an admin's page, covered by test/maintenance.test.ts", |
| 50 | 50 | "/claim": "answers 404 to an admin: it exists only for a link-holder", |
| 51 | 51 | "/first-admin": "the setup form for a forge with no admin yet", |
test/pricing-order.test.ts added +240
| @@ -0,0 +1,240 @@ | ||
| 1 | /** /pricing (#11138): order -> paid -> spawned -> claim link; duplicate delivery makes one forge; a failed spawn raises the alarm. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { placeOrder, reconcileOrder, resendLink, retryOrder, settleSession } from "../src/actions/order.ts"; | |
| 9 | import { useCheckout, type CheckoutProvider } from "../src/billing/checkout.ts"; | |
| 10 | import { bindCheckout } from "../src/billing/checkout_completed.ts"; | |
| 11 | import { CHECKOUT_MS, getOrder, HOLD_MS, MAX_PENDING_PER_ADDRESS } from "../src/billing/orders.ts"; | |
| 12 | import { getStored } from "../src/billing/store.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { firedAlarms } from "../src/flow/alarms.ts"; | |
| 15 | import { alarmPaidOrder } from "../src/flow/alarm_paid_order.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { ok } from "../src/kernel/result.ts"; | |
| 18 | import { bootHostedChild } from "../src/spawn/host.ts"; | |
| 19 | import { usePlacement, useProbe } from "../src/spawn/door.ts"; | |
| 20 | ||
| 21 | let dir: string; | |
| 22 | let app: App; | |
| 23 | let servers: Servers; | |
| 24 | let base: string; | |
| 25 | let admin: { id: string } & Record<string, any>; | |
| 26 | let full = false; | |
| 27 | let placed = 0; | |
| 28 | const paidSessions = new Set<string>(); | |
| 29 | const made: string[] = []; | |
| 30 | ||
| 31 | const fake: CheckoutProvider = { | |
| 32 | name: "fake", | |
| 33 | async create(req) { | |
| 34 | const id = `cs_fake_${req.orderId.slice(4, 16)}`; | |
| 35 | return ok({ id, url: `https://pay.fake.test/${id}` }); | |
| 36 | }, | |
| 37 | async state(id) { | |
| 38 | return ok({ paid: paidSessions.has(id), customer: "cus_1", subscription: "sub_1" }); | |
| 39 | }, | |
| 40 | }; | |
| 41 | ||
| 42 | beforeAll(async () => { | |
| 43 | dir = await mkdtemp(join(tmpdir(), "forge-pricing-")); | |
| 44 | app = createApp({ dataDir: dir, port: 0, forgeRepo: "alice/forge-code" }); | |
| 45 | servers = await serve(app); | |
| 46 | base = servers.url; | |
| 47 | const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 48 | if (!a.ok) throw new Error("user"); | |
| 49 | admin = a.value as any; | |
| 50 | const code = await createRepo(app, a.value, { name: "forge-code", visibility: "public" }); | |
| 51 | if (!code.ok) throw new Error("code repo"); | |
| 52 | await commitToBranch(app, a.value, code.value, { message: "v1", files: [{ path: "src/main.ts", content: "console.log('forge')" }] }); | |
| 53 | useProbe(app, () => async () => true); | |
| 54 | usePlacement(app, { | |
| 55 | place: async (plan) => (full ? { ok: false, error: { kind: "refused", reason: "capacity", message: "no room" } } as any : ok({ url: `https://${plan.name}.fake.test`, bodyId: `fake-${++placed}` })), | |
| 56 | retire: async () => ok(), | |
| 57 | }); | |
| 58 | useCheckout(app, fake); | |
| 59 | }, 30_000); | |
| 60 | ||
| 61 | afterAll(async () => { | |
| 62 | for (const n of made) await bootHostedChild(app, n); | |
| 63 | servers.stop(); | |
| 64 | app.close(); | |
| 65 | await rm(dir, { recursive: true, force: true }); | |
| 66 | }); | |
| 67 | ||
| 68 | const form = (o: Record<string, string>) => ({ method: "POST", redirect: "manual" as const, headers: { "content-type": "application/x-www-form-urlencoded", origin: base }, body: new URLSearchParams(o).toString() }); | |
| 69 | async function buy(name: string, plan = "small") { | |
| 70 | const res = await fetch(`${base}/pricing/order`, form({ name, plan, email: "[email protected]" })); | |
| 71 | const cookie = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 72 | const order = app.db.query<{ id: string; session_id: string }, [string]>("SELECT id, session_id FROM forge_order WHERE name = ?1 ORDER BY created_at DESC").get(name); | |
| 73 | return { res, cookie, order }; | |
| 74 | } | |
| 75 | const completed = (o: { id: string; session_id: string }) => ({ id: o.session_id, metadata: { order: o.id }, customer: "cus_1", subscription: "sub_1", payment_status: "paid" }); | |
| 76 | ||
| 77 | describe("/pricing", () => { | |
| 78 | test("shows the plans with sizes and prices to a stranger, and a way to the free path", async () => { | |
| 79 | const res = await fetch(`${base}/pricing`); | |
| 80 | expect(res.status).toBe(200); | |
| 81 | const text = await res.text(); | |
| 82 | expect(text).toContain("$19 a month"); | |
| 83 | expect(text).toContain("Start free"); | |
| 84 | expect(res.headers.get("content-security-policy")).toBeTruthy(); | |
| 85 | }); | |
| 86 | ||
| 87 | test("the free plan goes to /forges/new", async () => { | |
| 88 | const res = await fetch(`${base}/pricing/order`, form({ name: "gratis", plan: "free", email: "" })); | |
| 89 | expect(res.status).toBe(303); | |
| 90 | expect(res.headers.get("location")).toContain("/forges/new"); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a bad name, a bad email and a cross-origin post are refused; nothing is held", async () => { | |
| 94 | expect((await fetch(`${base}/pricing/order`, form({ name: "Bad Name", plan: "small", email: "[email protected]" }))).status).toBe(422); | |
| 95 | expect((await fetch(`${base}/pricing/order`, form({ name: "fine", plan: "small", email: "nope" }))).status).toBe(422); | |
| 96 | const evil = form({ name: "fine", plan: "small", email: "[email protected]" }); | |
| 97 | evil.headers.origin = "https://evil.test"; | |
| 98 | expect((await fetch(`${base}/pricing/order`, evil)).status).toBeGreaterThanOrEqual(400); | |
| 99 | expect(app.db.query("SELECT 1 FROM forge_order").all()).toEqual([]); | |
| 100 | }); | |
| 101 | ||
| 102 | test("order -> webhook paid -> spawned -> the claim link once; a duplicate delivery makes one forge", async () => { | |
| 103 | const { res, cookie, order } = await buy("shop"); | |
| 104 | expect(res.status).toBe(303); | |
| 105 | expect(res.headers.get("location")).toStartWith("https://pay.fake.test/"); | |
| 106 | expect(cookie).toStartWith("forge_order="); | |
| 107 | expect(res.headers.get("set-cookie")).toContain("HttpOnly"); | |
| 108 | expect(getOrder(app.db, order!.id)?.state).toBe("pending"); | |
| 109 | // The name is held for someone else. | |
| 110 | const other = await fetch(`${base}/pricing/order`, form({ name: "shop", plan: "small", email: "[email protected]" })); | |
| 111 | expect(other.status).toBe(422); | |
| 112 | // Two deliveries at once. | |
| 113 | await Promise.all([settleSession(app, completed(order!)), settleSession(app, completed(order!))]); | |
| 114 | await settleSession(app, completed(order!)); | |
| 115 | made.push("shop"); | |
| 116 | expect(getOrder(app.db, order!.id)?.state).toBe("spawned"); | |
| 117 | expect(placed).toBe(1); | |
| 118 | expect(getStored(app.db, "shop")?.plan).toBe("small"); | |
| 119 | const done = await fetch(`${base}/pricing/done?order=${order!.id}`, { headers: { cookie } }); | |
| 120 | const text = await done.text(); | |
| 121 | expect(text).toContain("shop is ready"); | |
| 122 | expect(text).toMatch(/claim|token=|#/i); | |
| 123 | const again = await fetch(`${base}/pricing/done?order=${order!.id}`, { headers: { cookie } }); | |
| 124 | expect(await again.text()).toContain("shown already"); | |
| 125 | // Someone without the buyer's cookie sees nothing. | |
| 126 | expect((await fetch(`${base}/pricing/done?order=${order!.id}`)).status).toBe(404); | |
| 127 | }); | |
| 128 | ||
| 129 | test("the return page reconciles by session id when no webhook came, once", async () => { | |
| 130 | const { order, cookie } = await buy("lates"); | |
| 131 | paidSessions.add(order!.session_id); | |
| 132 | const [a, b] = await Promise.all([reconcileOrder(app, order!.id), reconcileOrder(app, order!.id)]); | |
| 133 | made.push("lates"); | |
| 134 | expect([a?.state, b?.state].includes("spawned")).toBe(true); | |
| 135 | expect(getOrder(app.db, order!.id)?.state).toBe("spawned"); | |
| 136 | expect(placed).toBe(2); | |
| 137 | const done = await fetch(`${base}/pricing/done?order=${order!.id}`, { headers: { cookie } }); | |
| 138 | expect(await done.text()).toContain("lates is ready"); | |
| 139 | }); | |
| 140 | ||
| 141 | test("an event for a session that is not the order's settles nothing", async () => { | |
| 142 | const { order } = await buy("forged"); | |
| 143 | expect(await settleSession(app, { ...completed(order!), id: "cs_other" })).toBeUndefined(); | |
| 144 | expect(getOrder(app.db, order!.id)?.state).toBe("pending"); | |
| 145 | }); | |
| 146 | ||
| 147 | test("a paid order whose spawn fails raises the alarm, tells the buyer, and an admin retries it", async () => { | |
| 148 | full = true; | |
| 149 | const { order, cookie } = await buy("nospace"); | |
| 150 | await settleSession(app, completed(order!)); | |
| 151 | expect(getOrder(app.db, order!.id)?.state).toBe("failed"); | |
| 152 | const alarm = alarmPaidOrder(app.db); | |
| 153 | expect(alarm.fired).toBe(true); | |
| 154 | expect(alarm.says).toContain("nospace"); | |
| 155 | const done = await fetch(`${base}/pricing/done?order=${order!.id}`, { headers: { cookie } }); | |
| 156 | expect(await done.text()).toContain("paid but not made yet"); | |
| 157 | expect(firedAlarms(app.db, new Date(), { dataDir: null } as any).some((f) => f.key === "paid_order")).toBe(true); | |
| 158 | // A non-admin cannot retry; an admin can once there is room. | |
| 159 | expect((await retryOrder(app, undefined, order!.id)).ok).toBe(false); | |
| 160 | full = false; | |
| 161 | const retried = await retryOrder(app, admin as any, order!.id); | |
| 162 | made.push("nospace"); | |
| 163 | expect(retried.ok && retried.value.state).toBe("spawned"); | |
| 164 | expect(alarmPaidOrder(app.db).fired).toBe(false); | |
| 165 | }); | |
| 166 | ||
| 167 | test("with no provider the paid plans say ask the operator and a post builds nothing", async () => { | |
| 168 | useCheckout(app, undefined); | |
| 169 | const text = await (await fetch(`${base}/pricing`)).text(); | |
| 170 | expect(text).toContain("Ask the operator"); | |
| 171 | expect(text).not.toContain("$19 a month"); | |
| 172 | expect((await fetch(`${base}/pricing/order`, form({ name: "nope", plan: "small", email: "[email protected]" }))).status).toBe(409); | |
| 173 | useCheckout(app, fake); | |
| 174 | }); | |
| 175 | }); | |
| 176 | ||
| 177 | describe("/pricing: abuse and lifetimes", () => { | |
| 178 | const url = () => new URL(`${base}/pricing/order`); | |
| 179 | ||
| 180 | test("one address cannot hold more than its share of names; another address still can", async () => { | |
| 181 | for (let i = 0; i < MAX_PENDING_PER_ADDRESS; i++) { | |
| 182 | const r = await placeOrder(app, { name: `hoard${i}`, plan: "small", email: "[email protected]", address: "203.0.113.7" }, url()); | |
| 183 | expect(r.ok).toBe(true); | |
| 184 | } | |
| 185 | const over = await placeOrder(app, { name: "hoardx", plan: "small", email: "[email protected]", address: "203.0.113.7" }, url()); | |
| 186 | expect(over.ok).toBe(false); | |
| 187 | expect(app.db.query("SELECT 1 FROM forge_order WHERE name = 'hoardx'").all()).toEqual([]); | |
| 188 | const other = await placeOrder(app, { name: "honest", plan: "small", email: "[email protected]", address: "203.0.113.8" }, url()); | |
| 189 | expect(other.ok).toBe(true); | |
| 190 | }); | |
| 191 | ||
| 192 | test("the checkout stops being payable before the name's hold lapses", async () => { | |
| 193 | let seen: number | undefined; | |
| 194 | useCheckout(app, { ...fake, create: async (req) => { seen = req.expiresAt; return fake.create(req); } }); | |
| 195 | const now = Date.now(); | |
| 196 | const r = await placeOrder(app, { name: "latepay", plan: "small", email: "[email protected]", address: "198.51.100.1" }, url(), now); | |
| 197 | useCheckout(app, fake); | |
| 198 | expect(r.ok).toBe(true); | |
| 199 | expect(seen).toBeDefined(); | |
| 200 | // Stripe's least is 30 minutes; the hold is longer than the checkout. | |
| 201 | expect(seen! * 1000 - now).toBeGreaterThanOrEqual(30 * 60_000); | |
| 202 | expect(seen! * 1000).toBeLessThan(now + HOLD_MS); | |
| 203 | expect(CHECKOUT_MS).toBeLessThan(HOLD_MS); | |
| 204 | }); | |
| 205 | ||
| 206 | test("a claim link is mailed again at most once a day per order", async () => { | |
| 207 | const { order } = await buy("mailme"); | |
| 208 | await settleSession(app, completed(order!)); | |
| 209 | made.push("mailme"); | |
| 210 | const sent: unknown[] = []; | |
| 211 | (app.config as any).mailer = async (m: unknown) => { sent.push(m); return { ok: true }; }; | |
| 212 | try { | |
| 213 | const now = Date.now(); | |
| 214 | await resendLink(app, "mailme", "[email protected]", now); | |
| 215 | await resendLink(app, "mailme", "[email protected]", now + 11 * 60_000); | |
| 216 | await resendLink(app, "mailme", "[email protected]", now + 23 * 3_600_000); | |
| 217 | expect(sent.length).toBe(1); | |
| 218 | await resendLink(app, "mailme", "[email protected]", now + 25 * 3_600_000); | |
| 219 | expect(sent.length).toBe(2); | |
| 220 | } finally { | |
| 221 | delete (app.config as any).mailer; | |
| 222 | } | |
| 223 | }); | |
| 224 | ||
| 225 | test("binding a checkout twice, as the hook does for a spawned order, changes nothing the second time", async () => { | |
| 226 | const { order } = await buy("twice"); | |
| 227 | const event = { data: { object: { ...completed(order!), mode: "subscription", metadata: { order: order!.id, forge: "twice", plan: "small" } } } }; | |
| 228 | await settleSession(app, event.data.object); | |
| 229 | made.push("twice"); | |
| 230 | const before = getStored(app.db, "twice"); | |
| 231 | expect(bindCheckout(app, event)).toBe(true); | |
| 232 | const once = getStored(app.db, "twice"); | |
| 233 | expect(bindCheckout(app, event)).toBe(true); | |
| 234 | const { updatedAt: _a, ...again } = getStored(app.db, "twice")!; | |
| 235 | const { updatedAt: _b, ...first } = once!; | |
| 236 | expect(again).toEqual(first); | |
| 237 | expect(once?.plan).toBe(before?.plan); | |
| 238 | expect(once?.customerId).toBe("cus_1"); | |
| 239 | }); | |
| 240 | }); |
new above
offline
Name the terminal door here once the
src/tui/pricing screen exists (finding 1), and the per-address pending cap (finding 2).