Public: anyone can read this; the crew can change it.

Settings → Billing shows the selling funnel for the last 7 days: visits to ho… #11263

Merged opened this in answer to

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

Merged

torque/forge:torque/11259-settings-billing-shows-the-selling-funne → main 5 commits fork 2 threads · 2 unresolved

  • Merged. torque/11259-settings-billing-shows-the-selling-funne was merged into main as 5795cea.

Review

Sign in to review this change.
19 files changed +621 −4
docs/features/selling-funnel.md added +10
@@ -0,0 +1,10 @@
1 # The selling funnel
2
3 Settings → Billing, below Provider and Plans, has "Last 7 days: from visit to paid": one row per step — visits to the anonymous home, plans came into view, a plan's button pressed, forges made, entitlements active through a provider — each with its share of the step before; then a row per plan clicked and the top 5 referrers of visits that pressed a plan.
4 - In the terminal: the palette's "From visit to paid" (`tui/screens/funnel.ts`), the same rows, for a forge admin who is a person; `w` flips 7 and 30 days.
5
6 - `GET /api/v1/admin/billing/funnel?days=7` (a person admin; `days` 1 to 90) answers the same as JSON.
7 - `GET /api/v1/admin/traffic/probe?scope=funnel` counts each step again in SQL; `agrees` false and staying false is a claim to look at.
8 - Counts are the traffic view's own (`perf/funnel.ts`): scope `funnel` in `traffic_view`, the daily-salted visitor hash, scrapers never counted, no personal data, kept 90 days. Steps 1 to 3 count a visitor once a day; forges count forges; paid reads `entitlement_change` where the forge's provider is not `none`.
9 - The home's script (`web/funnel_door.ts`) posts to `/_funnel` (same-origin, rate-limited, nothing under Do-Not-Track); the plan must be one the forge offers. The free start is the "Get a forge" button, counted under the free plan's name.
10 - Not counted: forges made through the API or MCP (only the new-forge form), and the home served to a signed-in person.
src/contract/openapi/routes/getApiV1AdminBillingFunnel.ts added +11
@@ -0,0 +1,11 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/admin/billing/funnel",
6 summary:
7 "Read the selling funnel (a person admin's token, any scope); query: days (1 to 90, default 7); answers { as_of, days, steps: [{ id, label, count, pct }] for home, plans, click, forge, paid (pct is the share of the step before), clicks_by_plan: [{ plan, count }], referrers: [{ source, count }] (top 5 for visits that clicked a plan) }; scrapers are never counted; GET /api/v1/admin/traffic/probe?scope=funnel counts each step again",
8 auth: true,
9 role: "admin",
10 refusals: { not_found: "the token is not a person who administers the forge" },
11 };
src/contract/openapi/routes/index.ts +1
@@ -36,6 +36,7 @@ export { route as deleteApiV1UserPinsOwnerName } from "./deleteApiV1UserPinsOwne
3636 export { route as deleteApiV1UsersUsernameSessionsId } from "./deleteApiV1UsersUsernameSessionsId.ts";
3737 export { route as getApiV1 } from "./getApiV1.ts";
3838 export { route as getApiV1AdminAccounts } from "./getApiV1AdminAccounts.ts";
39 export { route as getApiV1AdminBillingFunnel } from "./getApiV1AdminBillingFunnel.ts";
3940 export { route as getApiV1AdminCapacity } from "./getApiV1AdminCapacity.ts";
4041 export { route as getApiV1AdminCrawlers } from "./getApiV1AdminCrawlers.ts";
4142 export { route as getApiV1AdminDrills } from "./getApiV1AdminDrills.ts";
src/http/api/admin.ts +10 −1
@@ -8,6 +8,7 @@
88
99 import { crawlReport } from "../../perf/crawlers.ts";
1010 import { getAppById } from "../../core/apps.ts";
11 import { FUNNEL_SCOPE, daysOf, funnelReport } from "../../perf/funnel.ts";
1112 import { probeViews } from "../../perf/views_probe.ts";
1213 import { kpiProbe } from "../../web/studio/kpi_probe.ts";
1314 import { MAX_SECONDS, sampleStacks } from "../../perf/sample.ts";
@@ -152,6 +153,14 @@ export function adminRoutes(router: Router<Handler>): void {
152153 return json(kpiProbe(ctx.app));
153154 });
154155
156 // The selling funnel for the last `days` (`perf/funnel.ts`): the same figures as Settings → Billing.
157 router.add("GET", "/api/v1/admin/billing/funnel", (ctx) => {
158 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
159 const denied = accountAdmin(ctx, "ro");
160 if (denied) return denied;
161 return json(funnelReport(ctx.app.db, daysOf(ctx.url.searchParams.get("days"))), 200, { "cache-control": "private, no-cache" });
162 });
163
155164 // The traffic view's numbers, each counted again another way (`perf/views_probe.ts`).
156165 // `scope` is `forge` or an app's id; `number`, `dimension`+`key` and `steps` pick what to show.
157166 router.add("GET", "/api/v1/admin/traffic/probe", (ctx) => {
@@ -160,7 +169,7 @@ export function adminRoutes(router: Router<Handler>): void {
160169 if (denied) return denied;
161170 const q = ctx.url.searchParams;
162171 const scope = q.get("scope") ?? "forge";
163 if (scope !== "forge" && getAppById(ctx.app.db, scope) === undefined) return notFound();
172 if (scope !== "forge" && scope !== FUNNEL_SCOPE && getAppById(ctx.app.db, scope) === undefined) return notFound();
164173 const probed = probeViews(ctx.app.db, scope, q.get("window"), q.get("who") === "scrapers" ? "scrapers" : "people", Date.now(), {
165174 dimension: q.get("dimension"),
166175 key: q.get("key"),
src/http/funnel.ts added +61
@@ -0,0 +1,61 @@
1 /**
2 * POST /_funnel: the home page's two funnel signals (#11259) — the plans came
3 * into view, a plan's button was pressed. Same rules as the page-view beacon
4 * (`http/measure.ts`): same-origin only, opted-out visitors store nothing, a
5 * rate per client, a tiny body, every field checked. The plan must be one the
6 * forge offers, so nothing a client invents is stored. Always an empty 204 or
7 * a short refusal.
8 */
9
10 import { RateLimiter } from "../core/ratelimit.ts";
11 import type { App } from "../app.ts";
12 import { getPlans } from "../settings/plans.ts";
13 import { optedOut } from "../web/measure.ts";
14 import { noteStep } from "../perf/funnel.ts";
15 import type { Ctx } from "./context.ts";
16 import { readBounded } from "./limits.ts";
17 import { clientAddress, clientBucket } from "./peer.ts";
18
19 /** What the home's buttons call the free start when no plan is free. */
20 export const START = "start";
21
22 const limits = new WeakMap<App, RateLimiter>();
23 const refuse = (status: number, headers?: Record<string, string>): Response =>
24 new Response(null, { status, headers: { "cache-control": "no-store", ...headers } });
25
26 export async function funnelSubmit(ctx: Ctx): Promise<Response> {
27 const { request, app } = ctx;
28 if (request.headers.get("origin") !== ctx.url.origin) return refuse(403);
29 if (optedOut(request)) return refuse(204);
30 let limiter = limits.get(app);
31 if (limiter === undefined) {
32 limiter = new RateLimiter({ perMinute: 30, adminPerMinute: 30, enabled: true });
33 limits.set(app, limiter);
34 }
35 const verdict = limiter.take(clientBucket(request), "ro");
36 if (!verdict.allowed) return refuse(429, { "retry-after": String(Math.max(1, verdict.retryAfter)) });
37 const bytes = await readBounded(request, 200);
38 if (bytes === "too_large") return refuse(413);
39 let raw: unknown;
40 try {
41 raw = JSON.parse(new TextDecoder().decode(bytes));
42 } catch {
43 return refuse(400);
44 }
45 if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return refuse(400);
46 const body = raw as Record<string, unknown>;
47 if (Object.keys(body).some((k) => k !== "step" && k !== "plan")) return refuse(400);
48 const client = clientAddress(request);
49 const agent = request.headers.get("user-agent") ?? "";
50 if (body["step"] === "plans" && body["plan"] === undefined) {
51 noteStep(app.db, { kind: "plans", client, agent });
52 return refuse(204);
53 }
54 if (body["step"] === "click" && typeof body["plan"] === "string") {
55 const known = body["plan"] === START || getPlans(app.db).some((p) => p.name === body["plan"]);
56 if (!known) return refuse(400);
57 noteStep(app.db, { kind: "click", plan: body["plan"], client, agent });
58 return refuse(204);
59 }
60 return refuse(400);
61 }
src/http/server.ts +2
@@ -275,6 +275,7 @@ import { familyPage } from "../web/pages/family.ts";
275275 import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeRemoveConfirm, forgeRemoveSubmit, forgeStopSubmit,
276276 forgeOwnCodeSubmit, forgesPage } from "../web/pages/forges.ts";
277277 import { forgeItSubmit } from "../web/pages/forge-it.ts";
278 import { funnelSubmit } from "./funnel.ts";
278279 import { newForgePage, newForgeSubmit } from "../web/pages/new-forge.ts";
279280 import { askedPage } from "../web/studio/asked_page.ts";
280281 import { notesOverrule, notesSave, notesView } from "../web/pages/notes.ts";
@@ -825,6 +826,7 @@ export function buildRouter(): Router<Handler> {
825826 router.add("GET", "/sw.js", serviceWorker);
826827 // The page-view beacon's report (http/measure.ts): numbers only, no IP kept.
827828 router.add("POST", "/_measure", (ctx) => measureSubmit(ctx, routeOf));
829 router.add("POST", "/_funnel", funnelSubmit);
828830 // What a page's Content-Security-Policy blocked (http/csp_report.ts): counts only.
829831 router.add("POST", "/csp-report", cspReportSubmit);
830832 router.add("GET", "/", home);
src/perf/funnel.ts added +137
@@ -0,0 +1,137 @@
1 /**
2 * The selling funnel (#11259): visits to the anonymous home, views where the
3 * plans came into view, clicks on each plan's button, forges made by a visitor,
4 * entitlements that became active through a provider. Steps 1-4 are page views
5 * of the scope `funnel` in `traffic_view` (so the same visitor hash, daily
6 * salt, scraper flag, caps and 90-day retention as the traffic view, and no
7 * personal data); the path names the step: `home`, `plans`, `click:<plan>`,
8 * `forge`. Step 5 is read from `entitlement_change`, where the provider wrote it.
9 *
10 * Counting: steps 1-3 count people (a visitor-day once), the forge step counts
11 * forges, the paid step counts changes. Scrapers are never counted. The
12 * percentage is a step over the step before it.
13 */
14
15 import type { Database } from "bun:sqlite";
16 import type { Origin } from "./sources.ts";
17 import { flushViews, noteView } from "./views_store.ts";
18 import { DAY_MS } from "./views_report.ts";
19
20 export const FUNNEL_SCOPE = "funnel";
21 export const FUNNEL_DAYS_MAX = 90;
22 export const NO_ORIGIN: Origin = { refHost: "", source: "", medium: "", campaign: "", out: "" };
23
24 export type StepKind = "home" | "plans" | "click" | "forge";
25
26 export interface Step {
27 readonly id: "home" | "plans" | "click" | "forge" | "paid";
28 readonly label: string;
29 readonly count: number;
30 /** Share of the step before, in percent with one decimal; null for the first or when that was zero. */
31 readonly pct: number | null;
32 }
33
34 export interface FunnelReport {
35 readonly as_of: string;
36 readonly days: number;
37 readonly steps: Step[];
38 readonly clicks_by_plan: { plan: string; count: number }[];
39 /** Where visits that clicked a plan came from, top 5; "(direct)" when no referrer or campaign. */
40 readonly referrers: { source: string; count: number }[];
41 }
42
43 /** `days` from the query: a whole number 1..90, else 7. */
44 export function daysOf(raw: string | null): number {
45 const n = raw !== null && /^\d{1,3}$/.test(raw) ? Number(raw) : 7;
46 return n >= 1 && n <= FUNNEL_DAYS_MAX ? n : 7;
47 }
48
49 export const pathOf = (kind: StepKind, plan = ""): string => (kind === "click" ? `click:${plan.slice(0, 60)}` : kind);
50
51 /** Note one step for a visitor; no SQL (the view buffer). */
52 export function noteStep(
53 db: Database,
54 step: { kind: StepKind; plan?: string; client: string; agent: string; origin?: Origin; at?: number },
55 ): boolean {
56 return noteView(db, {
57 scope: FUNNEL_SCOPE,
58 path: pathOf(step.kind, step.plan),
59 origin: step.origin ?? NO_ORIGIN,
60 client: step.client,
61 agent: step.agent,
62 ...(step.at === undefined ? {} : { at: step.at }),
63 });
64 }
65
66 const pct = (n: number, of: number): number | null => (of === 0 ? null : Math.round((n * 1000) / of) / 10);
67
68 /** Entitlements that became active through a provider since `since`: the sql route and the row route agree by construction of the probe. */
69 export function paidSql(db: Database, since: number): number {
70 return (
71 db
72 .query<{ n: number }, [string]>(
73 `SELECT COUNT(*) AS n FROM entitlement_change c
74 WHERE c.at >= ?1 AND c.to_status = 'active' AND (c.from_status != 'active' OR c.from_plan != c.to_plan)
75 AND EXISTS (SELECT 1 FROM entitlement e WHERE e.forge = c.forge AND e.provider NOT IN ('none', ''))`,
76 )
77 .get(new Date(since).toISOString())?.n ?? 0
78 );
79 }
80
81 interface Row {
82 day: string;
83 visitor: string;
84 path: string;
85 source: string;
86 }
87
88 export function funnelReport(db: Database, days: number, now: number = Date.now()): FunnelReport {
89 flushViews(db, now);
90 const since = now - days * DAY_MS;
91 const rows = db
92 .query<Row, [string, number]>(
93 "SELECT day, visitor, path, source FROM traffic_view WHERE scope = ?1 AND at >= ?2 AND scraper = 0 ORDER BY at, id",
94 )
95 .all(FUNNEL_SCOPE, since);
96 const people = { home: new Set<string>(), plans: new Set<string>(), click: new Set<string>() };
97 const byPlan = new Map<string, Set<string>>();
98 const sourceOf = new Map<string, string>();
99 let forges = 0;
100 for (const r of rows) {
101 const who = `${r.day}\0${r.visitor}`;
102 if (r.path === "home") {
103 people.home.add(who);
104 if (!sourceOf.has(who)) sourceOf.set(who, r.source);
105 } else if (r.path === "plans") people.plans.add(who);
106 else if (r.path === "forge") forges += 1;
107 else if (r.path.startsWith("click:")) {
108 people.click.add(who);
109 const plan = r.path.slice(6);
110 let set = byPlan.get(plan);
111 if (set === undefined) byPlan.set(plan, (set = new Set()));
112 set.add(who);
113 }
114 }
115 const sources = new Map<string, number>();
116 for (const who of people.click) {
117 const s = sourceOf.get(who) || "(direct)";
118 sources.set(s, (sources.get(s) ?? 0) + 1);
119 }
120 const counts = [people.home.size, people.plans.size, people.click.size, forges, paidSql(db, since)];
121 const labels: [Step["id"], string][] = [
122 ["home", "Visits to home"],
123 ["plans", "Saw the plans"],
124 ["click", "Clicked a plan"],
125 ["forge", "Forges made"],
126 ["paid", "Paid"],
127 ];
128 const top = <T extends { count: number }>(list: T[], key: (t: T) => string, n: number): T[] =>
129 list.sort((a, b) => b.count - a.count || key(a).localeCompare(key(b))).slice(0, n);
130 return {
131 as_of: new Date(now).toISOString(),
132 days,
133 steps: labels.map(([id, label], i) => ({ id, label, count: counts[i]!, pct: i === 0 ? null : pct(counts[i]!, counts[i - 1]!) })),
134 clicks_by_plan: top([...byPlan].map(([plan, s]) => ({ plan, count: s.size })), (t) => t.plan, 50),
135 referrers: top([...sources].map(([source, count]) => ({ source, count })), (t) => t.source, 5),
136 };
137 }
src/perf/views_probe.ts +29
@@ -7,6 +7,7 @@
77 */
88
99 import type { Database } from "bun:sqlite";
10 import { FUNNEL_SCOPE, funnelReport, paidSql } from "./funnel.ts";
1011 import {
1112 DIMENSIONS,
1213 DIRECT,
@@ -16,6 +17,7 @@ import {
1617 readVisits,
1718 sinceOf,
1819 windowOfViews,
20 WINDOWS,
1921 type Dimension,
2022 type Filter,
2123 type ViewWindow,
@@ -81,6 +83,7 @@ export function probeViews(
8183 one("SELECT COUNT(*) AS n FROM (SELECT DISTINCT day, visitor FROM traffic_view WHERE scope = ?1 AND at >= ?2 AND scraper = 1)", scope, since),
8284 { scope, window }),
8385 ];
86 if (scope === FUNNEL_SCOPE) numbers.push(...probeFunnel(db, window, since, now));
8487 const dimension = DIMENSIONS.find((d) => d === ask.dimension);
8588 if (dimension !== undefined && typeof ask.key === "string") {
8689 const key = ask.key === DIRECT && dimension === "source" ? "" : ask.key;
@@ -124,3 +127,29 @@ export function probeViews(
124127 }
125128 return { as_of: new Date(now).toISOString(), scope, window, numbers };
126129 }
130
131 /**
132 * The funnel's steps (`funnel.ts`), each counted again in SQL over the stored
133 * views (the report counts in memory); the paid step again from the change rows.
134 */
135 function probeFunnel(db: Database, window: ViewWindow, since: number, now: number): ProbedView[] {
136 const report = funnelReport(db, WINDOWS[window], now);
137 const one = (sql: string, ...args: (string | number)[]): number => db.query<{ n: number }, (string | number)[]>(sql).get(...args)?.n ?? 0;
138 const distinct = (like: string): number =>
139 one("SELECT COUNT(*) AS n FROM (SELECT DISTINCT day, visitor FROM traffic_view WHERE scope = ?1 AND at >= ?2 AND scraper = 0 AND path LIKE ?3)", FUNNEL_SCOPE, since, like);
140 const sql: Record<string, number> = {
141 home: distinct("home"),
142 plans: distinct("plans"),
143 click: distinct("click:%"),
144 forge: one("SELECT COUNT(*) AS n FROM traffic_view WHERE scope = ?1 AND at >= ?2 AND scraper = 0 AND path = 'forge'", FUNNEL_SCOPE, since),
145 paid: paidSql(db, since),
146 };
147 const counts: Record<string, string> = {
148 home: "Visitors who were served the anonymous home page, once a day each; scrapers apart.",
149 plans: "Visitors for whom the plans came into view, once a day each.",
150 click: "Visitors who pressed a plan's button (a payment link or the free start), once a day each.",
151 forge: "Forges made from the new-forge form (free or by code).",
152 paid: "Entitlements that became active through a billing provider.",
153 };
154 return report.steps.map((s) => made(`funnel_${s.id}`, s.label, counts[s.id]!, s.count, sql[s.id]!, { window, days: report.days }));
155 }
src/tui/screens/entries.ts +2
@@ -35,6 +35,7 @@ import { entry as disks } from "./disks.ts";
3535 import { entry as domains } from "./domains.ts";
3636 import { entry as edgeOnly } from "./edgeonly.ts";
3737 import { entry as traffic } from "./trafficview.ts";
38 import { entry as funnelEntry } from "./funnel.ts";
3839 import { entry as featureReview } from "./features.ts";
3940 import { entry as feed } from "./feed.ts";
4041 import { entry as featureFeed } from "./featurefeed.ts";
@@ -123,6 +124,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
123124 access,
124125 edgeOnly,
125126 traffic,
127 funnelEntry,
126128 audit,
127129 securityControls,
128130 secrets,
src/tui/screens/funnel.ts added +54
@@ -0,0 +1,54 @@
1 /**
2 * From visit to paid, in the terminal (#11259): the last 7 days of Settings →
3 * Billing's block (`perf/funnel.ts`), the same figures, offered to the same
4 * people: a forge admin who is a person. Anyone else is offered nothing and,
5 * were the screen reached, sees no rows. `w` flips 7 and 30 days. Read-only.
6 */
7
8 import { funnelReport, type FunnelReport } from "../../perf/funnel.ts";
9 import type { Ctx, Screen } from "../app/app.ts";
10 import { binding } from "../app/keys.ts";
11 import type { Session } from "../session.ts";
12 import { ListState, drawList, type ListRow } from "./common.ts";
13 import type { PaletteEntry } from "./entries.ts";
14
15 const mayRead = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind !== "agent";
16
17 /** One row per step with its share of the step before, then clicks by plan, then referrers. */
18 export function funnelRows(r: FunnelReport): ListRow[] {
19 const rows: ListRow[] = r.steps.map((s) => ({ title: s.label, meta: s.pct === null ? `${s.count}` : `${s.count} · ${s.pct}% of the step before` }));
20 for (const c of r.clicks_by_plan) rows.push({ title: `Clicked ${c.plan}`, meta: `${c.count}` });
21 for (const x of r.referrers) rows.push({ title: `Plan clicks from ${x.source}`, meta: `${x.count}` });
22 return rows;
23 }
24
25 export function funnelScreen(f: Session, ctx: Ctx): Screen {
26 const state = new ListState();
27 let days = 7;
28 const screen: Screen = {
29 title: "From visit to paid",
30 keys: [binding(["j", "k"], "move cursor"), binding(["w"], "7 or 30 days"), binding(["esc"], "back")],
31 render(area, s) {
32 const rows = mayRead(f) ? funnelRows(funnelReport(f.db, days)) : [];
33 state.clamp(rows.length);
34 drawList(s, area, ctx.theme.c, state, rows);
35 },
36 key(e) {
37 if (!mayRead(f)) return false;
38 if (e.key === "w") {
39 days = days === 7 ? 30 : 7;
40 return true;
41 }
42 return state.key(e, funnelRows(funnelReport(f.db, days)).length);
43 },
44 };
45 return screen;
46 }
47
48 export const entry: PaletteEntry = {
49 id: "from-visit-to-paid",
50 title: "From visit to paid",
51 hint: "the last 7 days: home visits, plans seen, plan clicks, forges made, paid",
52 offered: mayRead,
53 run: (f, ctx) => ctx.push(funnelScreen(f, ctx)),
54 };
src/web/funnel_door.ts added +46
@@ -0,0 +1,46 @@
1 /**
2 * The home's side of the selling funnel (#11259): the first step noted when a
3 * stranger is served the home, the name the free start goes by, the forge made
4 * from the form, and the small script that reports the plans coming into view
5 * and a plan's button pressed (`http/funnel.ts` receives it). Nothing is sent
6 * under Do-Not-Track or the opt-out cookie; the server checks both again.
7 */
8
9 import type { Ctx } from "../http/context.ts";
10 import { clientAddress } from "../http/peer.ts";
11 import { START } from "../http/funnel.ts";
12 import { noteStep } from "../perf/funnel.ts";
13 import { originOf } from "../perf/sources.ts";
14 import type { Plan } from "../settings/plans.ts";
15 import { optedOut } from "./measure.ts";
16
17 /** The free plan's name, or `start` when no plan is free. */
18 export const freePlanName = (plans: readonly Plan[]): string => plans.find((p) => p.priceUsd === 0)?.name ?? START;
19
20 const agentOf = (ctx: Ctx): string => ctx.request.headers.get("user-agent") ?? "";
21
22 export function noteHomeVisit(ctx: Ctx): void {
23 if (optedOut(ctx.request)) return;
24 noteStep(ctx.app.db, {
25 kind: "home",
26 client: clientAddress(ctx.request),
27 agent: agentOf(ctx),
28 // The referrer header, as the beacon's document.referrer, and the query for utm_ tags.
29 origin: originOf(ctx.request.headers.get("referer") ?? undefined, ctx.url.search, ctx.url.host, undefined),
30 });
31 }
32
33 /** A forge was made from the form; the visitor's hash is the home's, so the steps line up. */
34 export function noteForgeMade(ctx: Ctx): void {
35 if (optedOut(ctx.request)) return;
36 noteStep(ctx.app.db, { kind: "forge", client: clientAddress(ctx.request), agent: agentOf(ctx) });
37 }
38
39 /** Runs once the page is ready: plans in view once; a click on a [data-plan] sends its plan. */
40 export const funnelJs = `(function(n,d){
41 if(n.doNotTrack=="1"||/(^|; )forge_measure=off/.test(d.cookie)||!n.sendBeacon)return;
42 function p(o){n.sendBeacon("/_funnel",JSON.stringify(o))}
43 var l=d.querySelector(".door-plans");
44 if(l&&window.IntersectionObserver){var io=new IntersectionObserver(function(e){e.some(function(x){return x.isIntersecting})&&(io.disconnect(),p({step:"plans"}))});io.observe(l)}
45 d.addEventListener("click",function(e){var t=e.target,k=t&&t.closest&&t.closest("[data-plan]");k&&p({step:"click",plan:k.getAttribute("data-plan")})},true)
46 })(navigator,document)`;
src/web/pages/billing.ts +15
@@ -14,6 +14,7 @@ import { SIZES } from "../../spawn/sizes.ts";
1414 import { BILLING_SIZES, parseBillingLinks } from "../../settings/billing.ts";
1515 import { getPlans } from "../../settings/plans.ts";
1616 import { changeBillingLinks, readBillingLinks } from "../../actions/signup.ts";
17 import { funnelReport, type FunnelReport } from "../../perf/funnel.ts";
1718 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
1819 import { field, readForm } from "../form.ts";
1920 import { html, type Html } from "../html.ts";
@@ -119,6 +120,19 @@ export async function billingSetLinks(ctx: Ctx): Promise<Response> {
119120 return billingView(ctx, undefined, {}, undefined, 422, { values: next, error });
120121 }
121122
123 /** Last 7 days, from visit to paid: one `dl` row per step with its share of the step before; clicks by plan and where they came from as rows under it. */
124 function funnelRow(r: FunnelReport): Html {
125 const row = (label: string, value: string): Html => html`<div class="status-row"><dt>${label}</dt><dd>${value}</dd></div>`;
126 return html`<section class="settings-block" id="funnel">
127 <h2>Last 7 days: from visit to paid</h2>
128 <dl class="status-rows" data-funnel>
129 ${r.steps.map((s) => row(s.label, s.pct === null ? `${s.count}` : `${s.count} · ${s.pct}% of the step before`))}
outdated

Principle 1 and 8: "Selling funnel, 7 days" is jargon; say plainly what it is, e.g. "Last 7 days: from visit to paid". Also the block sits above Stripe and Plans, ahead of what this page is for (connecting billing, choosing plans); put it after them.

130 ${r.clicks_by_plan.map((c) => row(`Clicked ${c.plan}`, `${c.count}`))}
outdated

Principles 2 and 7: the real rows are hidden in one sentence of arrows with percentages and a plan breakdown in parentheses. Render each step as a dl row (label, count, share of the step before), clicks by plan and referrers as rows under it. This also wraps unpredictably on a phone and unverified at 1280px (principle 3).

131 ${r.referrers.map((x) => row(`Plan clicks from ${x.source}`, `${x.count}`))}
132 </dl>
133 </section>`;
134 }
135
122136 // GET /settings/billing
123137 export function billingPage(ctx: Ctx): Response {
124138 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -172,6 +186,7 @@ function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = {
172186 ? html`<p class="muted">No forge has been sent from here.</p>`
173187 : html`<ul class="list setting-list">${rows}</ul>`}
174188 </section>
189 ${funnelRow(funnelReport(ctx.app.db, 7))}
175190 ${linksSection(ctx, links)}
176191 ${codesSection(ctx, shown, values, error)}`;
177192 const response = page({
src/web/pages/new-forge.ts +2
@@ -33,6 +33,7 @@ import { dropGift, keepGift, keptGift } from "../gift_cookie.ts";
3333 import { notFoundPage, pageHead } from "../chrome.ts";
3434 import { field, readForm, type Form } from "../form.ts";
3535 import { html, type Html } from "../html.ts";
36 import { noteForgeMade } from "../funnel_door.ts";
3637 import { page } from "../layout.ts";
3738 import { pickList } from "../pick.ts";
3839 import { btn, field as formField, row, selectField } from "../ui.ts";
@@ -553,6 +554,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> {
553554 title: "New forge",
554555 body: formView(ctx, user, values, {}, planView(ctx, user, values, door.body), readsFor(ctx, user)),
555556 });
557 noteForgeMade(ctx);
556558 const response = page(
557559 { ctx, title: "New forge", body: madeView(door.body) },
558560 door.status,
src/web/plans_row.ts +1 −1
@@ -15,7 +15,7 @@ export function plansRow(plans: readonly Plan[], links: BillingLinks = {}, price
1515 return html`<ul class="door-plans">${plans.map((p) => {
1616 const w = planWords(p, priced, links);
1717 return html`<li><strong>${w.name}</strong> <span class="muted">${w.price}</span>${
18 w.buy === undefined ? "" : html` <a href="${w.buy}" rel="noopener noreferrer" target="_blank">Pay for ${w.name}</a>`
18 w.buy === undefined ? "" : html` <a href="${w.buy}" data-plan="${w.name}" rel="noopener noreferrer" target="_blank">Pay for ${w.name}</a>`
1919 }<span class="muted small">${w.is}</span></li>`;
2020 })}</ul>`;
2121 }
src/web/studio/home.ts +5 −2
@@ -25,6 +25,7 @@ import { accountsClosedIn } from "../../core/users.ts";
2525 import { markTold, untoldAnswer } from "../../core/requests.ts";
2626 import { requestsCard } from "../requests_card.ts";
2727 import { plansRow } from "../plans_row.ts";
28 import { freePlanName, funnelJs, noteHomeVisit } from "../funnel_door.ts";
2829 import { publicPlans } from "../../settings/plans.ts";
2930 import { getDoorSettings } from "../../settings/door.ts";
3031 import { GET_A_FORGE_PATH } from "../../billing/plan_words.ts";
@@ -772,6 +773,8 @@ export function studioDoor(ctx: Ctx): Response {
772773 // Render is what is left of the page once the shelf read is taken out.
773774 const rendering = performance.now();
774775 const visitor = ctx.auth.user === undefined;
776 // The funnel's first step: a stranger at the home (#11259), no SQL here.
777 if (visitor && ctx.url.pathname === "/" && ctx.request.method === "GET") noteHomeVisit(ctx);
775778 const admin = ctx.auth.user?.isAdmin === true && ctx.url.pathname === "/front";
776779 // A share sheet lands here with the shared words in the query; signing in
777780 // comes back to them rather than to a bare home, so they are not lost.
@@ -886,7 +889,7 @@ export function studioDoor(ctx: Ctx): Response {
886889 Hosted for you, or free and open source on a machine you keep.${accounts ? "" : mode === "invite" ? " New accounts on this forge are by invitation." : " This forge is not taking new accounts."}
887890 </p>
888891 ${plansRow(publicPlans(allPlans), links)}
889 <div class="bar">${btn("Get a forge", { href: GET_A_FORGE_PATH, variant: "primary", size: "lg" })}</div>
892 <div class="bar" data-plan="${freePlanName(allPlans)}">${btn("Get a forge", { href: GET_A_FORGE_PATH, variant: "primary", size: "lg" })}</div>
890893 <p class="door-support">Or run it yourself, free and open source: <a href="${FORGE_REPO}">read the step-by-step guide</a>.</p>
891894 <pre class="door-code"><code>git clone ${FORGE_REPO}.git
892895 cd forge
@@ -913,7 +916,7 @@ bun run src/main.ts</code></pre>
913916 title: "forge: software for what you're building, yours to keep",
914917 description: "Your own forge, with a private AI crew that builds your apps, games and business tools. Open source, and yours to keep.",
915918 body,
916 scriptJs: `${liveClientJs}\n${liveRegionsJs}`,
919 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${funnelJs}`,
917920 jsonLd: siteStructuredData(origin, running().version),
918921 ...(visitor
919922 ? {
test/forge-looks.test.ts +1
@@ -54,6 +54,7 @@ const NOT_A_PAGE: Record<string, string> = {
5454 "/verify": "answers 400 without the emailed code",
5555 "/logout": "a mutation, answers 404 to a GET",
5656 "/csp-report": "a mutation: where browsers report what the page policy blocked",
57 "/_funnel": "a mutation: the home page's beacon of funnel signals, POST only (#11259); covered by test/funnel.test.ts",
5758 "/theme": "a mutation, answers 404 to a GET",
5859 "/tell": "answers 404 to a GET",
5960 "/asks": "redirects to /#needs-you",
test/funnel.test.ts added +167
@@ -0,0 +1,167 @@
1 /**
2 * The selling funnel (#11259): a visitor goes home → plan click → free forge
3 * and each count moves by one; a scraper's visit moves none; a provider's
4 * activation is the paid step; Settings → Billing renders the row and
5 * `/api/v1/admin/billing/funnel` and the traffic probe say the same.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { putEntitlement } from "../src/billing/store.ts";
13 import { createSession } from "../src/core/sessions.ts";
14 import { createToken } from "../src/core/tokens.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { ok } from "../src/kernel/result.ts";
18 import { daysOf, funnelReport } from "../src/perf/funnel.ts";
19 import { funnelRows } from "../src/tui/screens/funnel.ts";
20 import { funnelJs } from "../src/web/funnel_door.ts";
21 import { getPlans } from "../src/settings/plans.ts";
22 import { bootHostedChild } from "../src/spawn/host.ts";
23 import { usePlacement, useProbe } from "../src/spawn/door.ts";
24 import { quietLogs } from "./helpers/globals.ts";
25
26 quietLogs();
27
28 const PERSON = "Mozilla/5.0 (X11; Linux x86_64) Chrome/130.0 Safari/537.36";
29 const BOT = "Mozilla/5.0 (compatible; Reflectionbot/1.0)";
30
31 let dir: string;
32 let app: App;
33 let servers: Servers;
34 let cookie = "";
35 let token = "";
36 let freePlan = "";
37
38 const counts = () => Object.fromEntries(funnelReport(app.db, 7).steps.map((s) => [s.id, s.count]));
39 const home = (agent: string, extra: Record<string, string> = {}) =>
40 fetch(`${servers.url}/`, { headers: { "user-agent": agent, ...extra } });
41 const signal = (agent: string, body: unknown, extra: Record<string, string> = {}) =>
42 fetch(`${servers.url}/_funnel`, { method: "POST", headers: { "user-agent": agent, origin: servers.url, ...extra }, body: JSON.stringify(body) });
43 const makeForge = (agent: string, name: string) =>
44 fetch(`${servers.url}/forges/new`, {
45 method: "POST",
46 redirect: "manual",
47 headers: { "user-agent": agent, origin: servers.url, "content-type": "application/x-www-form-urlencoded" },
48 body: new URLSearchParams({ name }),
49 });
50
51 beforeAll(async () => {
52 dir = await mkdtemp(join(tmpdir(), "forge-funnel-"));
53 app = createApp({ dataDir: dir, port: 0 });
54 servers = await serve(app);
55 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "ada", email: "[email protected]", password: "correct-horse-battery", adminIfFirst: true });
56 if (!a.ok) throw new Error(a.error);
57 cookie = `forge_session=${createSession(app.db, a.value.id).token}`;
58 token = createToken(app.db, a.value.id, "ro", "ro").token;
59 freePlan = getPlans(app.db).find((p) => p.priceUsd === 0)?.name ?? "start";
60 useProbe(app, () => async () => true);
61 usePlacement(app, { place: async (p) => ok({ url: `https://${p.name}.fake.test`, bodyId: `fake-${p.name}` }), retire: async () => ok() });
62 }, 30_000);
63
64 afterAll(async () => {
65 await bootHostedChild(app, "funnel-one");
66 servers.stop();
67 app.close();
68 await rm(dir, { recursive: true, force: true });
69 });
70
71 describe("the selling funnel", () => {
72 test("a visitor moves each step by one; a scraper moves none; the home carries the plan hooks", async () => {
73 expect(counts()).toEqual({ home: 0, plans: 0, click: 0, forge: 0, paid: 0 });
74 const page = await (await home(PERSON)).text();
75 expect(page).toContain(`data-plan="${freePlan}"`);
76 expect(funnelJs).toContain("/_funnel");
77 expect(page).toContain("page-");
78 expect(counts()).toMatchObject({ home: 1 });
79 expect((await signal(PERSON, { step: "plans" })).status).toBe(204);
80 expect(counts()).toMatchObject({ home: 1, plans: 1 });
81 expect((await signal(PERSON, { step: "click", plan: freePlan })).status).toBe(204);
82 expect(counts()).toMatchObject({ home: 1, plans: 1, click: 1 });
83 const made = await makeForge(PERSON, "funnel-one");
84 expect(made.status).toBeLessThan(400);
85 expect(counts()).toEqual({ home: 1, plans: 1, click: 1, forge: 1, paid: 0 });
86 // The same visitor again the same day is the same visit.
87 await home(PERSON);
88 await signal(PERSON, { step: "plans" });
89 expect(counts()).toMatchObject({ home: 1, plans: 1 });
90
91 const before = counts();
92 await home(BOT);
93 await signal(BOT, { step: "plans" });
94 await signal(BOT, { step: "click", plan: freePlan });
95 expect(counts()).toEqual(before);
96 });
97
98 test("a plan the forge does not offer, a stray field or a cross-origin post stores nothing", async () => {
99 const before = counts();
100 expect((await signal("Mozilla/5.0 Firefox/130", { step: "click", plan: "nonesuch" })).status).toBe(400);
101 expect((await signal("Mozilla/5.0 Firefox/130", { step: "click", plan: freePlan, who: "me" })).status).toBe(400);
102 expect((await signal("Mozilla/5.0 Firefox/130", { step: "other" })).status).toBe(400);
103 expect((await signal("Mozilla/5.0 Firefox/130", { step: "plans" }, { origin: "https://evil.example" })).status).toBe(403);
104 expect((await signal("Mozilla/5.0 Firefox/130", { step: "plans" }, { dnt: "1" })).status).toBe(204);
105 expect(counts()).toEqual(before);
106 });
107
108 test("a provider's activation is the paid step; a hand grant is not", () => {
109 putEntitlement(app.db, { forge: "payer", plan: "medium", status: "active", source: "granted", provider: "none" }, "small", "x");
110 expect(counts()).toMatchObject({ paid: 0 });
111 putEntitlement(app.db, { forge: "payer2", plan: "medium", status: "active", source: "stripe", provider: "stripe" }, "small", "");
112 expect(counts()).toMatchObject({ paid: 1 });
113 expect(funnelReport(app.db, 7).steps.find((s) => s.id === "paid")!.pct).toBe(100);
114 });
115
116 test("referrers of visits that clicked a plan are listed, top five, direct apart", async () => {
117 const agent = "Mozilla/5.0 Safari/605 Referred";
118 await home(agent, { referer: "https://news.example/story?x=1" });
119 await signal(agent, { step: "click", plan: freePlan });
120 const r = funnelReport(app.db, 7);
121 expect(r.referrers).toContainEqual({ source: "news.example", count: 1 });
122 expect(r.referrers).toContainEqual({ source: "(direct)", count: 1 });
123 expect(r.clicks_by_plan).toEqual([{ plan: freePlan, count: 2 }]);
124 expect(r.referrers.length).toBeLessThanOrEqual(5);
125 });
126
127 test("Settings → Billing renders the row; the API and the probe say the same", async () => {
128 const r = funnelReport(app.db, 7);
129 const page = await (await fetch(`${servers.url}/settings/billing`, { headers: { cookie } })).text();
130 const block = /<dl class="status-rows" data-funnel>([\s\S]*?)<\/dl>/.exec(page)?.[1] ?? "";
131 const rows = [...block.matchAll(/<dt>([^<]*)<\/dt><dd>([^<]*)<\/dd>/g)].map((m) => [m[1], m[2]]);
132 expect(rows.slice(0, 5).map((x) => x[0])).toEqual(["Visits to home", "Saw the plans", "Clicked a plan", "Forges made", "Paid"]);
133 expect(rows[0]![1]).toBe(`${r.steps[0]!.count}`);
134 expect(rows[1]![1]).toStartWith(`${r.steps[1]!.count}`);
135 expect(rows[1]![1]).toContain("of the step before");
136 expect(rows).toContainEqual([`Clicked ${freePlan}`, "2"]);
137 expect(page).toContain("Plan clicks from ");
138 expect(page).toContain("Last 7 days: from visit to paid");
139 expect(page).not.toContain("→ ");
140 expect(page.indexOf('id="funnel"')).toBeGreaterThan(page.indexOf("<h2>Plans</h2>"));
141
142 const api = await fetch(`${servers.url}/api/v1/admin/billing/funnel?days=7`, { headers: { authorization: `Bearer ${token}` } });
143 expect(api.status).toBe(200);
144 const body = (await api.json()) as typeof r;
145 expect(body.days).toBe(7);
146 expect(body.steps.map((s) => s.count)).toEqual(r.steps.map((s) => s.count));
147 expect((await fetch(`${servers.url}/api/v1/admin/billing/funnel`)).status).toBe(404);
148
149 const probe = await fetch(`${servers.url}/api/v1/admin/traffic/probe?scope=funnel`, { headers: { authorization: `Bearer ${token}` } });
150 const probed = (await probe.json()) as { numbers: { id: string; value: number; recomputed: number; agrees: boolean }[] };
151 const steps = probed.numbers.filter((n) => n.id.startsWith("funnel_"));
152 expect(steps.map((n) => n.id)).toEqual(["funnel_home", "funnel_plans", "funnel_click", "funnel_forge", "funnel_paid"]);
153 expect(steps.every((n) => n.agrees)).toBe(true);
154 expect(steps.map((n) => n.value)).toEqual(r.steps.map((s) => s.count));
155 });
156
157 test("the terminal's rows are the page's: a step, its share, clicks by plan", () => {
158 const r = funnelReport(app.db, 7);
159 const rows = funnelRows(r);
160 expect(rows.slice(0, 5).map((x) => x.title)).toEqual(r.steps.map((x) => x.label));
161 expect(rows.some((x) => x.title === `Clicked ${freePlan}` && x.meta === "2")).toBe(true);
162 });
163
164 test("days is a whole number 1 to 90, else a week", () => {
165 expect([daysOf(null), daysOf("30"), daysOf("0"), daysOf("91"), daysOf("x"), daysOf("1e2")]).toEqual([7, 30, 7, 7, 7, 7]);
166 });
167 });
test/palette-roster.test.ts +1
@@ -164,6 +164,7 @@ test("a site-admin person is offered the whole roster", () => {
164164 ["devices", "Devices", "LAN printers: model, state, supplies; declare or remove one"],
165165 ["edge-only-accounts", "Edge-only accounts", "who would be locked out if the platform edge sign-in went off"],
166166 ["traffic-sources", "Traffic sources", "where people come from and where they go, for the forge"],
167 ["from-visit-to-paid", "From visit to paid", "the last 7 days: home visits, plans seen, plan clicks, forges made, paid"],
167168 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
168169 ["security-controls", "Security controls", "each safeguard, and when it was last tested"],
169170 ["billing", "Billing", "each forge's plan, and grant one by hand"],
test/tui-funnel.test.ts added +66
@@ -0,0 +1,66 @@
1 /** From visit to paid in the terminal (#11259): an admin person is offered it and reads the steps; a member is offered nothing. */
2 import { afterAll, beforeAll, 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 as Forge } from "../src/app.ts";
7 import { createUser, getUserByName } from "../src/core/users.ts";
8 import { createForgeApp } from "../src/tui/forge.ts";
9 import type { TuiIdentity } from "../src/tui/session.ts";
10 import { headlessTerminal } from "../src/tui/term/terminal.ts";
11 import { quietLogs } from "./helpers/globals.ts";
12 import { settleApp } from "./helpers/tui.ts";
13
14 quietLogs();
15 let dir: string;
16 let forge: Forge;
17
18 function boot(username: string) {
19 const user = getUserByName(forge.db, username)!;
20 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
21 const { app } = createForgeApp(forge, identity, { terminal: headlessTerminal(140, 32), exitHooks: false });
22 app.start();
23 const k = (key: string, ctrl = false) => app.dispatch({ type: "key", key, ctrl, alt: false, shift: false });
24 return {
25 text: () => app.screen.toText(),
26 palette: async (query: string) => {
27 k("k", true);
28 for (const ch of query) k(ch);
29 await settleApp(app);
30 const seen = app.screen.toText();
31 k("enter");
32 await settleApp(app);
33 return seen;
34 },
35 stop: () => app.stop(),
36 };
37 }
38
39 beforeAll(async () => {
40 dir = await mkdtemp(join(tmpdir(), "forge-tui-funnel-"));
41 forge = createApp({ dataDir: dir, port: 0 });
42 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
43 const made = await createUser(forge.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst });
44 if (!made.ok) throw new Error(made.error);
45 }
46 });
47 afterAll(async () => {
48 forge.close();
49 await rm(dir, { recursive: true, force: true });
50 });
51
52 test("an admin opens it and reads every step", async () => {
53 const h = boot("alice");
54 await h.palette("From visit to paid");
55 const t = h.text();
56 for (const label of ["Visits to home", "Saw the plans", "Clicked a plan", "Forges made", "Paid"]) expect(t).toContain(label);
57 h.stop();
58 });
59
60 test("a member is offered nothing", async () => {
61 const h = boot("bob");
62 const seen = await h.palette("From visit to paid");
63 expect(seen).not.toContain("the last 7 days: home visits");
64 expect(h.text()).not.toContain("Visits to home");
65 h.stop();
66 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs