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

The plans on the home page show their price and what you get, and nothing bes… #11261

Merged opened this in answer to

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

Merged

scout/forge:scout/11258-the-plans-on-the-home-page-show-their-pr → main 7 commits fork 7 threads · 7 unresolved

  • Merged. scout/11258-the-plans-on-the-home-page-show-their-pr was merged into main as 28b4394.

Review

Sign in to review this change.
8 files changed +105 −103
docs/features/home-plans.md +5 −12
@@ -10,21 +10,14 @@ Where a visitor sees what a hosted forge is and how to get one (#11139).
1010
1111 ## What a plan line says
1212
13 The plan's name, its price, the machine it runs on (CPU, memory, disk) and its own sentence. The words are `src/billing/plan_words.ts`, shared by both doors. A plan whose size has a [buy link](billing-links.md) also shows "Pay for <plan>".
13 The plan's name, its price ("Free", or "$19 a month" from the plan's `priceUsd`), the machine it runs on (CPU, memory, disk) and its own sentence. A paid plan with a crew credit adds the credit after that sentence: "$5 of crew credit each month", from the plan's `creditUsd`. The words are `src/billing/plan_words.ts`, shared by both doors.
14

Document the credit line, the Get <plan> link and the after-payment line; fix the double blank line below.

15 A paid plan whose size has a [buy link](billing-links.md) also carries a "Get <plan>" link to it (on the web an anchor beside the price, in the terminal `Get <plan>: <link>`); the free plan never does, its way in is the one **Get a forge** button. When any plan carries a link, one line follows the plans on both doors: "Your forge is made after payment; an email with the link to open it arrives." (`AFTER_PAYMENT` in the same file).
16
17 Whether this forge takes new accounts is not said in this section: it is about getting a forge, not an account here.
1418
1519 The plans themselves are the forge's plans setting (`src/settings/plans.ts`; edit them at `/admin/limits`, the palette's *Plans*, or MCP `limits`).
1620
17 ## FORGE_BILLING_PROVIDER
18
19 Which payment provider the forge sells through, read on every request (no restart needed):
20
21 | value | what the plans show |
22 | --- | --- |
23 | `stripe` | each paid plan's monthly price, e.g. "$19 a month"; the free plan reads "Free" |
24 | unset, `none`, or anything else | no price: a paid plan reads "Paid", the free plan "Free" |
25
26 It only changes what is shown; it charges nothing by itself.
27
2821 ## Cost
2922
3023 The home reads who may sign up, the plans and the buy links in one statement (`src/settings/door.ts`), so the section adds no query to the page's statement budget. A stored value that does not parse reads as its default.
src/billing/plan_words.ts +13 −9
@@ -1,9 +1,8 @@
11 /**
22 * A plan in plain words, the same on the home page and in the terminal: what
33 * it costs, what it is (the machine it runs on and what it says), and where to
4 * pay for it when a buy link is set (`settings/billing.ts`). With provider
5 * `none` (`billing/provider.ts`) no price is shown: a paid plan reads "Paid",
6 * the free plan still reads "Free", so the two never look the same.
4 * pay for it when a buy link is set (`settings/billing.ts`). Price and crew
5 * credit come from the plans setting.
76 */
87
98 import type { BillingLinks } from "../settings/billing.ts";
@@ -12,23 +11,28 @@ import type { Plan } from "../settings/plans.ts";
1211
1312 export interface PlanWords {
1413 readonly name: string;
15 /** "Free", "$19 a month", or "Paid" when prices are not shown. */
14 /** "Free" or "$19 a month". */
1615 readonly price: string;
17 /** The machine and the plan's own line: "1 CPU, 2 GB memory, 2 GB disk. A small forge…". */
16 /** The machine, the plan's own line, then a paid plan's credit: "1 CPU, 2 GB memory, 2 GB disk. A small forge… $5 of crew credit each month." */
1817 readonly is: string;
1918 /** The https buy link for the plan's size, only on a paid plan and only when set. */
2019 readonly buy: string | undefined;
2120 }
2221
23 export function planWords(p: Plan, priced: boolean, links: BillingLinks = {}): PlanWords {
22 export function planWords(p: Plan, links: BillingLinks = {}): PlanWords {
2423 const free = p.priceUsd === 0;
24 const buy = free ? undefined : links[p.size];
25 const credit = !free && p.creditUsd > 0 ? ` $${p.creditUsd} of crew credit each month.` : "";
outdated

Paid plans lose p.says here; keep the plan's own sentence and add the credit after it.

2526 return {
2627 name: p.name,
27 price: free ? "Free" : priced ? `$${p.priceUsd} a month` : "Paid",
28 is: `${sizeWords(p.size)}. ${p.says}`,
29 buy: free ? undefined : links[p.size],
28 price: free ? "Free" : `$${p.priceUsd} a month`,
29 is: `${sizeWords(p.size)}. ${p.says}${credit}`,
30 buy,
3031 };
3132 }
3233
outdated

With no buy links and no Stripe provider the price still reads "Paid", and the preview shows it on Small, Medium and Large. The issue asked that the plans show their price and nothing besides; either show the price from the plans setting whenever the plan has one, or explain in the pull why "Paid" stays.

34 /** Said under the plans, on the web and in the terminal, whenever a plan carries a buy link. */
35 export const AFTER_PAYMENT = "Your forge is made after payment; an email with the link to open it arrives.";
36
3337 /** Where the home's one button and the terminal's pointer send a person for a hosted forge. */
3438 export const GET_A_FORGE_PATH = "/forges/new";
src/billing/provider.ts deleted −11
@@ -1,11 +0,0 @@
1 /**
2 * Which payment provider this forge sells through: `stripe` or `none`
3 * (`FORGE_BILLING_PROVIDER`; anything else reads as `none`). With `none`
4 * nothing is charged, so pages show the plans without prices.
5 */
6
7 export type BillingProvider = "none" | "stripe";
8
9 export function billingProvider(env: Record<string, string | undefined> = process.env): BillingProvider {
10 return env["FORGE_BILLING_PROVIDER"] === "stripe" ? "stripe" : "none";
11 }
src/tui/screens/getforge.ts +10 −13
@@ -1,14 +1,12 @@
11 /**
22 * "Get your own forge" in the terminal: the home page's `#yours` for anyone,
3 * guests included. Every plan with its price (or "Paid" while
4 * `FORGE_BILLING_PROVIDER` is not `stripe`), what it is, its buy link when
3 * guests included. Every plan with its price, what it is, its buy link when
54 * one is set, then where to get a hosted forge and how to run one yourself.
65 * The words are `billing/plan_words.ts`, the same the web row prints; the
76 * settings are read in one statement as the home reads them.
87 */
98
10 import { billingProvider } from "../../billing/provider.ts";
11 import { GET_A_FORGE_PATH, planWords } from "../../billing/plan_words.ts";
9 import { AFTER_PAYMENT, GET_A_FORGE_PATH, planWords } from "../../billing/plan_words.ts";
1210 import { UPSTREAM_REPO } from "../../core/upstream.ts";
1311 import { getDoorSettings } from "../../settings/door.ts";
1412 import { publicPlans } from "../../settings/plans.ts";
@@ -21,23 +19,22 @@ import type { Session } from "../session.ts";
2119 import { Scroll } from "./common.ts";
2220
2321 /** The screen as text lines, read fresh on every draw. */
24 export function getForgeLines(app: App, width = 80, priced = billingProvider() === "stripe"): string[] {
22 export function getForgeLines(app: App, width = 80): string[] {
2523 const w = Math.max(20, width);
2624 const { plans, links } = getDoorSettings(app.db);
2725 const origin = (app.config.url ?? "").replace(/\/$/, "");
26 const words = publicPlans(plans).map((p) => planWords(p, links));
2827 return [
2928 "Get your own forge",
3029 ...wrap("Hosted for you, or free and open source on a machine you keep.", w),
3130 "",
3231 "Plans",
33 ...publicPlans(plans).flatMap((p) => {
34 const words = planWords(p, priced, links);
35 return [
36 ` ${words.name} ${words.price}`,
37 ...wrap(words.is, w - 4).map((l) => ` ${l}`),
38 ...(words.buy === undefined ? [] : [` Pay: ${words.buy}`]),
39 ];
40 }),
32 ...words.flatMap((p) => [
33 ` ${p.name} ${p.price}`,
34 ...wrap(p.is, w - 4).map((l) => ` ${l}`),
35 ...(p.buy === undefined ? [] : [` Get ${p.name}: ${p.buy}`]),
36 ]),
37 ...(words.some((p) => p.buy !== undefined) ? ["", ...wrap(AFTER_PAYMENT, w)] : []),
4138 "",
4239 `Get a forge: ${origin}${GET_A_FORGE_PATH}`,
4340 "",
src/web/plans_row.ts +12 −10
@@ -1,21 +1,23 @@
11 /**
22 * The plans in one compact list (name, price, what it is) for the anonymous
33 * home's "Get your own forge". The one button beside it is the home's; a plan
4 * with a buy link set (`settings/billing.ts`) carries a plain "Pay" link, not
5 * a second button. Words come from `billing/plan_words.ts`, the terminal's too.
4 * with a buy link set (`settings/billing.ts`) carries a plain "Get <plan>" link,
5 * not a second button, and the after-payment line follows the list. Words come from `billing/plan_words.ts`, the terminal's too.
66 */
77
8 import { billingProvider } from "../billing/provider.ts";
9 import { planWords } from "../billing/plan_words.ts";
8 import { AFTER_PAYMENT, planWords } from "../billing/plan_words.ts";
109 import type { BillingLinks } from "../settings/billing.ts";
1110 import type { Plan } from "../settings/plans.ts";

Principle 1: say this once. Share the const with the terminal (billing/plan_words.ts) so both doors say it.

1211 import { html, type Html } from "./html.ts";
1312
14 export function plansRow(plans: readonly Plan[], links: BillingLinks = {}, priced = billingProvider() === "stripe"): Html {
15 return html`<ul class="door-plans">${plans.map((p) => {
16 const w = planWords(p, priced, links);
17 return html`<li><strong>${w.name}</strong> <span class="muted">${w.price}</span>${
18 w.buy === undefined ? "" : html` <a href="${w.buy}" data-plan="${w.name}" rel="noopener noreferrer" target="_blank">Pay for ${w.name}</a>`
19 }<span class="muted small">${w.is}</span></li>`;
13 export function plansRow(plans: readonly Plan[], links: BillingLinks = {}): Html {
14 const words = plans.map((p) => planWords(p, links));
outdated

Principle 1: AFTER_PAYMENT is exported but nothing else uses it, so make it a plain const. It also promises "usually within a few hours", which is a commitment the code cannot back. State what happens (an email with the link arrives) and drop the time.

15 const list = html`<ul class="door-plans">${words.map((w) => {
16 const action =
17 w.buy !== undefined
18 ? html` <a href="${w.buy}" data-plan="${w.name}" rel="noopener noreferrer" target="_blank">Get ${w.name}</a>`
19 : "";
20 return html`<li><strong>${w.name}</strong> <span class="muted">${w.price}</span>${action}<span class="muted small">${w.is}</span></li>`;
2021 })}</ul>`;
22 return words.some((w) => w.buy !== undefined) ? html`${list}<p class="muted small">${AFTER_PAYMENT}</p>` : list;
outdated

Principle 1 (nothing explains itself twice): the Free row gets a "Get Free" link to /forges/new, and right under the list the "Get a forge" button goes to the same place. Drop the Free action here and let the one primary button carry it.

2123 }
src/web/studio/home.ts +2 −1
@@ -890,7 +890,7 @@ export function studioDoor(ctx: Ctx): Response {
890890 <div class="stack">
891891 <h2>Get your own forge</h2>
892892 <p class="door-support">
893 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."}
893 Hosted for you, or free and open source on a machine you keep.
894894 </p>
895895 ${plansRow(publicPlans(allPlans), links)}
896896 <div class="bar" data-plan="${freePlanName(allPlans)}">${btn("Get a forge", { href: GET_A_FORGE_PATH, variant: "primary", size: "lg" })}</div>
@@ -913,6 +913,7 @@ bun run src/main.ts</code></pre>
913913 Running this for a team? <a href="https://www.open-platform.sh/#platform">The Platform</a> runs on your own cloud, and
914914 <a href="https://www.espodev.com">EspoDev</a> delivers enterprise implementations.
915915 </p>
916 ${accounts ? "" : html`<p class="muted small">${mode === "invite" ? "New accounts on this forge are by invitation." : "This forge is not taking new accounts."}</p>`}

On a closed or invite-only forge the plans section still offers a Get link, and the reason it will be refused now sits after the team paragraph, far from the button. Put this line next to the Get a forge button so a person reads it before clicking. Principle 1 applies here too: say it once.

Principle 1/8: the reason a forge is closed is now far from the button it qualifies. Place it next to the Get a forge button.

916917 ${when(admin, () => html`<div class="door-section front-admin">${frontControls(ctx, front)}</div>`)}
917918 </div>`;
918919 const res = page({
test/plans-row.test.ts +60 −44
@@ -1,7 +1,7 @@
11 /**
22 * The anonymous home's plans (#11139): each plan says what it costs and what
3 * it is; with provider `none` a paid plan reads "Paid" and the free plan
4 * "Free", with `stripe` the prices show; one button to /forges/new. The home
3 * it is; the price is the plans
4 * setting's, the free plan reads "Free"; one button to /forges/new. The home
55 * reads signup, plans and buy links in one statement (`settings/door.ts`),
66 * each falling back to its default on a stored value that does not parse.
77 * The terminal's "Get your own forge" prints the same words to a guest.
@@ -12,8 +12,7 @@ import { tmpdir } from "node:os";
1212 import { join } from "node:path";
1313 import { createApp, type App } from "../src/app.ts";
1414 import { serve, type Servers } from "../src/http/server.ts";
15 import { billingProvider } from "../src/billing/provider.ts";
16 import { planWords } from "../src/billing/plan_words.ts";
15 import { AFTER_PAYMENT, planWords } from "../src/billing/plan_words.ts";
1716 import { DEFAULT_PLANS } from "../src/settings/plans.ts";
1817 import { getDoorSettings } from "../src/settings/door.ts";
1918 import { sizeWords } from "../src/settings/billing.ts";
@@ -25,41 +24,33 @@ import { forgeCommands, Session } from "../src/tui/forge.ts";
2524 const FREE = DEFAULT_PLANS.find((p) => p.priceUsd === 0)!;
2625 const SMALL = DEFAULT_PLANS.find((p) => p.key === "small")!;
2726
28 test("provider none: a paid plan reads Paid, the free plan Free, no dollar price; each says what it is", () => {
29 const none = plansRow(DEFAULT_PLANS, {}, false).toString();
27 test("every plan shows its price from the plans setting, the free plan Free; no Paid anywhere", () => {
28 const row = plansRow(DEFAULT_PLANS, {}).toString();
3029 for (const p of DEFAULT_PLANS) {
31 expect(none).toContain(p.name);
32 expect(none).toContain(sizeWords(p.size));
30 expect(row).toContain(p.name);
31 expect(row).toContain(sizeWords(p.size));
3332 }
34 expect(none).not.toContain("$");
35 expect(none).not.toContain("a month");
36 expect(planWords(FREE, false).price).toBe("Free");
37 expect(planWords(SMALL, false).price).toBe("Paid");
33 for (const t of ["$19 a month", "$49 a month", "$149 a month", "$5 of crew credit each month"]) expect(row).toContain(t);
34 expect(row).not.toContain("Paid");
35 expect(planWords(FREE).price).toBe("Free");
36 expect(planWords(SMALL).price).toBe("$19 a month");
3837 });
3938
40 test("provider stripe: the prices show; the free plan is still Free", () => {
41 const priced = plansRow(DEFAULT_PLANS, {}, true).toString();
42 expect(priced).toContain("$19 a month");
43 expect(priced).toContain("$149 a month");
44 expect(planWords(FREE, true).price).toBe("Free");
39 test("with no buy links the plans carry no action of their own: the one button is the home's", () => {
40 const row = plansRow(DEFAULT_PLANS, {}).toString();
41 expect(row).not.toContain("<a ");
42 expect(row).not.toContain("after payment");
4543 });
4644
4745 test("a buy link shows on a paid plan of its size, never on the free plan", () => {
4846 const links = { small: "https://buy.example/small" };
49 expect(planWords(SMALL, false, links).buy).toBe(links.small);
50 expect(planWords(FREE, false, links).buy).toBeUndefined();
51 });
52
53 test("the provider is none unless stripe is named", () => {
54 expect(billingProvider({})).toBe("none");
55 expect(billingProvider({ FORGE_BILLING_PROVIDER: "x" })).toBe("none");
56 expect(billingProvider({ FORGE_BILLING_PROVIDER: "stripe" })).toBe("stripe");
47 expect(planWords(SMALL, links).buy).toBe(links.small);
48 expect(planWords(FREE, links).buy).toBeUndefined();
5749 });
5850
5951 let dataDir: string;
6052 let app: App;
6153 let servers: Servers;
62 const saved = process.env["FORGE_BILLING_PROVIDER"];
6354
6455 beforeAll(async () => {
6556 dataDir = await mkdtemp(join(tmpdir(), "forge-plans-row-"));
@@ -68,8 +59,6 @@ beforeAll(async () => {
6859 });
6960
7061 afterEach(() => {
71 if (saved === undefined) delete process.env["FORGE_BILLING_PROVIDER"];
72 else process.env["FORGE_BILLING_PROVIDER"] = saved;
7362 app.db.run("DELETE FROM forge_setting WHERE key IN ('signup', 'plans', 'billing.links')");
7463 });
7564
@@ -89,26 +78,17 @@ const yours = async (): Promise<string> => {
8978 };
9079
9180 test("the signed-out home shows each plan and one button to a page that exists", async () => {
92 delete process.env["FORGE_BILLING_PROVIDER"];
9381 const section = await yours();
9482 for (const p of DEFAULT_PLANS) expect(section).toContain(p.name);
9583 expect(section).toContain('href="/forges/new"');
9684 expect(section).not.toContain("/pricing");
97 expect(section).not.toContain("$19 a month");
98 expect(section).toContain("Paid");
85 expect(section).toContain("$19 a month");
86 expect(section).not.toContain("Paid");
87 expect(section).not.toContain("Get Free");
9988 expect(section.match(/data-size="lg"/g)).toHaveLength(1);
10089 expect((await fetch(`${servers.url}/forges/new`)).status).toBe(200);
10190 });
10291
103 test("with FORGE_BILLING_PROVIDER=stripe the real home shows the prices, read per request", async () => {
104 process.env["FORGE_BILLING_PROVIDER"] = "stripe";
105 const section = await yours();
106 expect(section).toContain("$19 a month");
107 expect(section).toContain("$49 a month");
108 expect(section).toContain("$149 a month");
109 expect(section).not.toContain(">Paid<");
110 });
111
11292 test("getDoorSettings reads signup, plans and links in one statement", () => {
11393 put("signup", "invite");
11494 put("billing.links", JSON.stringify({ medium: "https://buy.example/medium" }));
@@ -130,16 +110,52 @@ test("getDoorSettings: nothing stored, or a stored value that does not parse, re
130110 });
131111
132112 test("the terminal's Get your own forge: plans, the pointer to /forges/new and self-hosting", () => {
133 const lines = getForgeLines(app, 80, false).join("\n");
113 const lines = getForgeLines(app).join("\n");
134114 for (const p of DEFAULT_PLANS) expect(lines).toContain(p.name);
135 expect(lines).toContain("Paid");
136 expect(lines).not.toContain("$19 a month");
115 expect(lines).toContain("$19 a month");
116 expect(lines).not.toContain("Paid");
137117 expect(lines).toContain("/forges/new");
138118 expect(lines).toContain("git clone");
139 expect(getForgeLines(app, 80, true).join("\n")).toContain("$19 a month");
140119 });
141120
142121 test("a guest in the terminal is offered Get your own forge", () => {
143122 const guest = new Session(app, { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: undefined });
144123 expect(forgeCommands(guest)({} as never).map((c) => c.id)).toContain("get-a-forge");
145124 });
125
126 test("a paid plan keeps its own sentence and adds its credit after it", () => {
127 expect(planWords(SMALL).is).toBe(`${sizeWords(SMALL.size)}. ${SMALL.says} $${SMALL.creditUsd} of crew credit each month.`);
128 expect(planWords(FREE).is).toBe(`${sizeWords(FREE.size)}. ${FREE.says}`);
129 });
130
131 test("the terminal says Get <plan> and the after-payment line when links are set, neither without", () => {
132 expect(getForgeLines(app, 200).join("\n")).not.toContain(AFTER_PAYMENT);
133 put("billing.links", JSON.stringify({ small: "https://buy.example/s" }));
134 const lines = getForgeLines(app, 200);
135 expect(lines).toContain(" Get Small: https://buy.example/s");
136 expect(lines).toContain(AFTER_PAYMENT);
137 expect(lines.join("\n")).not.toContain("Pay");
138 });
139
140 test("with buy links set the plans show price, credit, Get anchors and the after-payment line; no Paid, Pay for, or closed-forge text", async () => {
141 put("signup", "closed");
142 put("billing.links", JSON.stringify({ small: "https://buy.example/s", medium: "https://buy.example/m", large: "https://buy.example/l" }));
143 const section = await yours();
144 for (const t of ["$19 a month", "$49 a month", "$149 a month", "$5 of crew credit each month", "$15 of crew credit each month", "$50 of crew credit each month", "Your forge is made after payment"])
145 expect(section).toContain(t);
146 for (const n of ["Small", "Medium", "Large"]) expect(section).toContain(`>Get ${n}</a>`);
147 expect(section).not.toContain("Get Free");
148 expect(section).not.toContain("usually within");
149 expect(section).not.toContain("Paid");
150 expect(section).not.toContain("Pay for");
151 expect(section).not.toContain("not taking new accounts");
152 });
153
154 test("changing a price in the plans setting changes the page", async () => {
155 put("billing.links", JSON.stringify({ small: "https://buy.example/s" }));
156 put("plans", JSON.stringify(DEFAULT_PLANS.map((p) => (p.key === "small" ? { ...p, priceUsd: 23, creditUsd: 7 } : p))));
157 const section = await yours();
158 expect(section).toContain("$23 a month");
159 expect(section).toContain("$7 of crew credit each month");
160 expect(section).not.toContain("$19 a month");
161 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs