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

The payment links from #11149 can be set from Settings → Billing and by API;… #11185

Merged opened this in answer to

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

Merged

mason/forge:mason/11184-the-payment-links-from-11149-can-be-set → main 5 commits fork 2 threads · 2 unresolved

  • Merged. mason/11184-the-payment-links-from-11149-can-be-set was merged into main as 2305da7.

Review

Sign in to review this change.
7 files changed +127 −3
src/contract/openapi/routes/index.ts +1
@@ -376,6 +376,7 @@ export { route as postApiV1UserOriginsOauth } from "./postApiV1UserOriginsOauth.
376376 export { route as postApiV1UserPassword } from "./postApiV1UserPassword.ts";
377377 export { route as postApiV1Users } from "./postApiV1Users.ts";
378378 export { route as postMcp } from "./postMcp.ts";
379 export { route as putApiV1AdminBillingLinks } from "./putApiV1AdminBillingLinks.ts";
379380 export { route as putApiV1AdminCapacityKey } from "./putApiV1AdminCapacityKey.ts";
380381 export { route as putApiV1AdminLimitsKey } from "./putApiV1AdminLimitsKey.ts";
381382 export { route as putApiV1AdminMachinesMachineDisksDevice } from "./putApiV1AdminMachinesMachineDisksDevice.ts";
src/http/api/admin.ts +11
@@ -268,6 +268,17 @@ export function adminRoutes(router: Router<Handler>): void {
268268 return apiError("invalid_plans", 422, changed.error.invalid);
269269 });
270270
271 // The payment links (`settings/billing.ts`): the whole object is replaced; the REST twin of MCP `signup` with billingLinks.
272 router.add("PUT", "/api/v1/admin/billing/links", async (ctx) => {
273 const denied = accountAdmin(ctx, "admin");
274 if (denied) return denied;
275 const input = await body(ctx.request);
276 const set = changeBillingLinks(ctx.app, ctx.auth.user, input["links"]);
277 if (set.ok) return json({ links: set.value });
278 if (set.error === "not_found") return notFound();
279 return apiError(set.error, 422, BILLING_WORDS);
280 });
281
271282 // Per-account quotas (`settings/quotas.ts`), key by key: the REST twin of MCP `quotas`.
272283 router.add("GET", "/api/v1/admin/quotas", (ctx) => {
273284 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
src/http/settings_routes.ts +2 −1
@@ -18,7 +18,7 @@ import { teamPage, teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts
1818 import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts";
1919 import { flagsPage, flagsSet } from "../web/pages/flags.ts";
2020 import { appTemplatesPage, appTemplatesSet } from "../web/pages/app_templates.ts";
21 import { billingPage, billingGrant, billingMakeCode, billingRevokeCode, stripeConnect, stripeDisconnect, stripeSecret } from "../web/pages/billing.ts";
21 import { billingPage, billingGrant, billingSetLinks, billingMakeCode, billingRevokeCode, stripeConnect, stripeDisconnect, stripeSecret } from "../web/pages/billing.ts";
2222 import { economicsPage, economicsSet } from "../web/pages/economics.ts";
2323 import { trustedAdd, trustedPage, trustedRemove, trustedRevert, trustedStop } from "../web/pages/trusted.ts";
2424 import { usagePage } from "../web/pages/usage.ts";
@@ -210,6 +210,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
210210 router.add("POST", "/settings/economics", economicsSet);
211211 router.add("GET", "/settings/billing", billingPage);
212212 router.add("POST", "/settings/billing/grant", billingGrant);
213 router.add("POST", "/settings/billing/links", billingSetLinks);
213214 router.add("POST", "/settings/billing/stripe/connect", stripeConnect);
214215 router.add("POST", "/settings/billing/stripe/secret", stripeSecret);
215216 router.add("POST", "/settings/billing/stripe/disconnect", stripeDisconnect);
src/web/pages/billing.ts +45 −1
@@ -11,6 +11,9 @@ import { publicOrigin } from "../../core/public_origin.ts";
1111 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1212 import type { Ctx } from "../../http/context.ts";
1313 import { SIZES } from "../../spawn/sizes.ts";
14 import { BILLING_SIZES, parseBillingLinks } from "../../settings/billing.ts";
15 import { getPlans } from "../../settings/plans.ts";
16 import { changeBillingLinks, readBillingLinks } from "../../actions/signup.ts";
1417 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
1518 import { field, readForm } from "../form.ts";
1619 import { html, type Html } from "../html.ts";
@@ -76,6 +79,46 @@ function codesSection(ctx: Ctx, shown: Shown | undefined, values: Record<string,
7679
7780 const codeWords = (e: CodeError): string => (e === "not_found" ? "Not found" : CODE_ERRORS[e]);
7881
82 /** The Payment links section: one https field per size a paid plan sits on, saved in one submit. */
83 function linksSection(ctx: Ctx, retry?: { values: Record<string, string>; error: string }): Html | null {
84 const links = readBillingLinks(ctx.app, ctx.auth.user);
85 if (!links.ok) return null;
86 const paid = getPlans(ctx.app.db).filter((p) => p.priceUsd > 0);
87 const fields = BILLING_SIZES.map((z) => {
88 const named = paid.filter((p) => p.size === z).map((p) => p.name).join(", ");
89 return { size: z, named };
90 }).filter((f) => f.named !== "" || links.value[f.size] !== undefined);
91 return html`<section class="settings-block" id="links">
92 <h2>Payment links</h2>
93 <p class="muted small">Where a buyer pays for each paid plan. With any set, the home page offers paid forges; empty clears one.</p>
94 ${retry === undefined ? null : html`<p class="error" role="alert">${retry.error}</p>`}
95 <form class="stack" method="post" action="${PATH}/links">
96 ${fields.map((f) =>
97 formField({ label: `${f.named === "" ? f.size : f.named} (${f.size})`, name: f.size, type: "url", value: retry?.values[f.size] ?? links.value[f.size] ?? "", hint: "https://" }),
98 )}
99 ${btn("Save payment links", { variant: "primary" })}
100 </form>
101 </section>`;
102 }
103
104 // POST /settings/billing/links
105 export async function billingSetLinks(ctx: Ctx): Promise<Response> {
106 if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") return notFoundPage(ctx);
107 const form = await readForm(ctx, "admin");
108 if (form instanceof Response) return form;
109 const current = readBillingLinks(ctx.app, ctx.auth.user);
110 const next: Record<string, string> = current.ok ? { ...current.value } : {};
111 // Only the fields the form carries change; a size without a field keeps its link.
112 for (const z of BILLING_SIZES) if (form.has(z)) next[z] = field(form, z);
113 const changed = changeBillingLinks(ctx.app, ctx.auth.user, next);
114 if (!changed.ok && changed.error === "not_found") return notFoundPage(ctx);
115 if (changed.ok) return flashTo(ctx, `${PATH}#links`, { message: "Payment links saved." });
outdated

The error flash is generic and the typed values are lost on redirect; name the size that was refused so the admin can fix it (principle 8, plain words that say what to do).

116 // Name the sizes refused, and show the form again with what was typed.
117 const refused = BILLING_SIZES.filter((z) => !parseBillingLinks({ [z]: next[z] }).ok);
118 const error = `${refused.join(" and ")}: each payment link is an https address with no spaces, or empty. Nothing was saved.`;
119 return billingView(ctx, undefined, {}, undefined, 422, { values: next, error });
120 }
121
79122 // GET /settings/billing
80123 export function billingPage(ctx: Ctx): Response {
81124 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -83,7 +126,7 @@ export function billingPage(ctx: Ctx): Response {
83126 return billingView(ctx);
84127 }
85128
86 function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = {}, error?: string, status = 200): Response {
129 function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = {}, error?: string, status = 200, links?: { values: Record<string, string>; error: string }): Response {
87130 const read = readBilling(ctx.app, ctx.auth.user);
88131 if (!read.ok) return notFoundPage(ctx);
89132 const b = read.value;
@@ -129,6 +172,7 @@ function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = {
129172 ? html`<p class="muted">No forge has been sent from here.</p>`
130173 : html`<ul class="list setting-list">${rows}</ul>`}
131174 </section>
175 ${linksSection(ctx, links)}
132176 ${codesSection(ctx, shown, values, error)}`;
133177 const response = page({
134178 ctx,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs