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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11258-the-plans-on-the-home-page-show-their-pr → main 7 commits fork 7 threads · 7 unresolved
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). | ||
| 10 | 10 | |
| 11 | 11 | ## What a plan line says |
| 12 | 12 | |
| 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 [](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 | ||
| 15 | A paid plan whose size has a [](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. | |
| 14 | 18 | |
| 15 | 19 | 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`). |
| 16 | 20 | |
| 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 | ||
| 28 | 21 | ## Cost |
| 29 | 22 | |
| 30 | 23 | 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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * A plan in plain words, the same on the home page and in the terminal: what |
| 3 | 3 | * 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. | |
| 7 | 6 | */ |
| 8 | 7 | |
| 9 | 8 | import type { BillingLinks } from "../settings/billing.ts"; |
| @@ -12,23 +11,28 @@ import type { Plan } from "../settings/plans.ts"; | ||
| 12 | 11 | |
| 13 | 12 | export interface PlanWords { |
| 14 | 13 | readonly name: string; |
| 15 | /** "Free", "$19 a month", or "Paid" when prices are not shown. */ | |
| 14 | /** "Free" or "$19 a month". */ | |
| 16 | 15 | 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." */ | |
| 18 | 17 | readonly is: string; |
| 19 | 18 | /** The https buy link for the plan's size, only on a paid plan and only when set. */ |
| 20 | 19 | readonly buy: string | undefined; |
| 21 | 20 | } |
| 22 | 21 | |
| 23 | export function planWords(p: Plan, priced: boolean, links: BillingLinks = {}): PlanWords { | |
| 22 | export function planWords(p: Plan, links: BillingLinks = {}): PlanWords { | |
| 24 | 23 | 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. |
||
| 25 | 26 | return { |
| 26 | 27 | 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, | |
| 30 | 31 | }; |
| 31 | 32 | } |
| 32 | 33 | |
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 | ||
| 33 | 37 | /** Where the home's one button and the terminal's pointer send a person for a hosted forge. */ |
| 34 | 38 | 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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * "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 | |
| 5 | 4 | * one is set, then where to get a hosted forge and how to run one yourself. |
| 6 | 5 | * The words are `billing/plan_words.ts`, the same the web row prints; the |
| 7 | 6 | * settings are read in one statement as the home reads them. |
| 8 | 7 | */ |
| 9 | 8 | |
| 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"; | |
| 12 | 10 | import { UPSTREAM_REPO } from "../../core/upstream.ts"; |
| 13 | 11 | import { getDoorSettings } from "../../settings/door.ts"; |
| 14 | 12 | import { publicPlans } from "../../settings/plans.ts"; |
| @@ -21,23 +19,22 @@ import type { Session } from "../session.ts"; | ||
| 21 | 19 | import { Scroll } from "./common.ts"; |
| 22 | 20 | |
| 23 | 21 | /** 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[] { | |
| 25 | 23 | const w = Math.max(20, width); |
| 26 | 24 | const { plans, links } = getDoorSettings(app.db); |
| 27 | 25 | const origin = (app.config.url ?? "").replace(/\/$/, ""); |
| 26 | const words = publicPlans(plans).map((p) => planWords(p, links)); | |
| 28 | 27 | return [ |
| 29 | 28 | "Get your own forge", |
| 30 | 29 | ...wrap("Hosted for you, or free and open source on a machine you keep.", w), |
| 31 | 30 | "", |
| 32 | 31 | "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)] : []), | |
| 41 | 38 | "", |
| 42 | 39 | `Get a forge: ${origin}${GET_A_FORGE_PATH}`, |
| 43 | 40 | "", |
src/web/plans_row.ts +12 −10
| @@ -1,21 +1,23 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The plans in one compact list (name, price, what it is) for the anonymous |
| 3 | 3 | * 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. | |
| 6 | 6 | */ |
| 7 | 7 | |
| 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"; | |
| 10 | 9 | import type { BillingLinks } from "../settings/billing.ts"; |
| 11 | 10 | 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. |
||
| 12 | 11 | import { html, type Html } from "./html.ts"; |
| 13 | 12 | |
| 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>`; | |
| 20 | 21 | })}</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. |
||
| 21 | 23 | } |
src/web/studio/home.ts +2 −1
| @@ -890,7 +890,7 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 890 | 890 | <div class="stack"> |
| 891 | 891 | <h2>Get your own forge</h2> |
| 892 | 892 | <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. | |
| 894 | 894 | </p> |
| 895 | 895 | ${plansRow(publicPlans(allPlans), links)} |
| 896 | 896 | <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> | ||
| 913 | 913 | Running this for a team? <a href="https://www.open-platform.sh/#platform">The Platform</a> runs on your own cloud, and |
| 914 | 914 | <a href="https://www.espodev.com">EspoDev</a> delivers enterprise implementations. |
| 915 | 915 | </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. |
||
| 916 | 917 | ${when(admin, () => html`<div class="door-section front-admin">${frontControls(ctx, front)}</div>`)} |
| 917 | 918 | </div>`; |
| 918 | 919 | const res = page({ |
test/billing-links.test.ts +3 −3
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The bridge to paid hosting (#11149): `billing.links` is empty by default and |
| 3 | 3 | * the anonymous home shows the plans with no pay link; with links set, each |
| 4 | * paid plan whose size has a link carries a plain "Pay for <plan>" link to it, | |
| 4 | * paid plan whose size has a link carries a plain "Get <plan>" link to it, | |
| 5 | 5 | * beside the home's one "Get a forge" button (#11139). |
| 6 | 6 | */ |
| 7 | 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| @@ -109,7 +109,7 @@ test("a bad link is refused and nothing is kept", async () => { | ||
| 109 | 109 | }); |
| 110 | 110 | |
| 111 | 111 | const yours = (text: string): string => text.slice(text.indexOf('id="yours"'), text.indexOf('class="door-org"')); |
| 112 | const payAnchors = (section: string): string[] => [...section.matchAll(/<a\s[^>]*>Pay for [^<]*<\/a\s*>/g)].map((m) => m[0]); | |
| 112 | const payAnchors = (section: string): string[] => [...section.matchAll(/<a\s[^>]*href="https:[^>]*>Get [^<]*<\/a\s*>/g)].map((m) => m[0]); | |
| 113 | 113 | |
| 114 | 114 | test("with three links each paid plan carries a pay link; the button stays one", async () => { |
| 115 | 115 | const res = await patch(adminToken, { billingLinks: LINKS }); |
| @@ -125,7 +125,7 @@ test("with three links each paid plan carries a pay link; the button stays one", | ||
| 125 | 125 | expect(section).toContain(sizeWords(size)); |
| 126 | 126 | } |
| 127 | 127 | expect(section.match(/>Get a forge</g)).toHaveLength(1); |
| 128 | expect(section).not.toContain("Pay for Free"); | |
| 128 | expect(section).not.toContain("Pay for"); | |
| 129 | 129 | }); |
| 130 | 130 | |
| 131 | 131 | test("a size without a link has no pay link; clearing every link restores the page", async () => { |
test/plans-row.test.ts +60 −44
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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 | |
| 5 | 5 | * reads signup, plans and buy links in one statement (`settings/door.ts`), |
| 6 | 6 | * each falling back to its default on a stored value that does not parse. |
| 7 | 7 | * The terminal's "Get your own forge" prints the same words to a guest. |
| @@ -12,8 +12,7 @@ import { tmpdir } from "node:os"; | ||
| 12 | 12 | import { join } from "node:path"; |
| 13 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 14 | 14 | 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"; | |
| 17 | 16 | import { DEFAULT_PLANS } from "../src/settings/plans.ts"; |
| 18 | 17 | import { getDoorSettings } from "../src/settings/door.ts"; |
| 19 | 18 | import { sizeWords } from "../src/settings/billing.ts"; |
| @@ -25,41 +24,33 @@ import { forgeCommands, Session } from "../src/tui/forge.ts"; | ||
| 25 | 24 | const FREE = DEFAULT_PLANS.find((p) => p.priceUsd === 0)!; |
| 26 | 25 | const SMALL = DEFAULT_PLANS.find((p) => p.key === "small")!; |
| 27 | 26 | |
| 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(); | |
| 30 | 29 | 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)); | |
| 33 | 32 | } |
| 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"); | |
| 38 | 37 | }); |
| 39 | 38 | |
| 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"); | |
| 45 | 43 | }); |
| 46 | 44 | |
| 47 | 45 | test("a buy link shows on a paid plan of its size, never on the free plan", () => { |
| 48 | 46 | 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(); | |
| 57 | 49 | }); |
| 58 | 50 | |
| 59 | 51 | let dataDir: string; |
| 60 | 52 | let app: App; |
| 61 | 53 | let servers: Servers; |
| 62 | const saved = process.env["FORGE_BILLING_PROVIDER"]; | |
| 63 | 54 | |
| 64 | 55 | beforeAll(async () => { |
| 65 | 56 | dataDir = await mkdtemp(join(tmpdir(), "forge-plans-row-")); |
| @@ -68,8 +59,6 @@ beforeAll(async () => { | ||
| 68 | 59 | }); |
| 69 | 60 | |
| 70 | 61 | afterEach(() => { |
| 71 | if (saved === undefined) delete process.env["FORGE_BILLING_PROVIDER"]; | |
| 72 | else process.env["FORGE_BILLING_PROVIDER"] = saved; | |
| 73 | 62 | app.db.run("DELETE FROM forge_setting WHERE key IN ('signup', 'plans', 'billing.links')"); |
| 74 | 63 | }); |
| 75 | 64 | |
| @@ -89,26 +78,17 @@ const yours = async (): Promise<string> => { | ||
| 89 | 78 | }; |
| 90 | 79 | |
| 91 | 80 | test("the signed-out home shows each plan and one button to a page that exists", async () => { |
| 92 | delete process.env["FORGE_BILLING_PROVIDER"]; | |
| 93 | 81 | const section = await yours(); |
| 94 | 82 | for (const p of DEFAULT_PLANS) expect(section).toContain(p.name); |
| 95 | 83 | expect(section).toContain('href="/forges/new"'); |
| 96 | 84 | 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"); | |
| 99 | 88 | expect(section.match(/data-size="lg"/g)).toHaveLength(1); |
| 100 | 89 | expect((await fetch(`${servers.url}/forges/new`)).status).toBe(200); |
| 101 | 90 | }); |
| 102 | 91 | |
| 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 | ||
| 112 | 92 | test("getDoorSettings reads signup, plans and links in one statement", () => { |
| 113 | 93 | put("signup", "invite"); |
| 114 | 94 | 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 | ||
| 130 | 110 | }); |
| 131 | 111 | |
| 132 | 112 | 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"); | |
| 134 | 114 | 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"); | |
| 137 | 117 | expect(lines).toContain("/forges/new"); |
| 138 | 118 | expect(lines).toContain("git clone"); |
| 139 | expect(getForgeLines(app, 80, true).join("\n")).toContain("$19 a month"); | |
| 140 | 119 | }); |
| 141 | 120 | |
| 142 | 121 | test("a guest in the terminal is offered Get your own forge", () => { |
| 143 | 122 | const guest = new Session(app, { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: undefined }); |
| 144 | 123 | expect(forgeCommands(guest)({} as never).map((c) => c.id)).toContain("get-a-forge"); |
| 145 | 124 | }); |
| 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 | }); |
Document the credit line, the Get <plan> link and the after-payment line; fix the double blank line below.