create-ailk 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +11 -3
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/lib/license-key-files.d.ts +6 -3
  11. package/dist/lib/license-key-files.js +8 -2
  12. package/dist/lib/scaffold-render-sweep.d.ts +32 -0
  13. package/dist/lib/scaffold-render-sweep.js +76 -3
  14. package/dist/license-set.d.ts +1 -1
  15. package/dist/license-set.js +13 -8
  16. package/dist/module-architecture.js +128 -3
  17. package/dist/programmatic.js +11 -1
  18. package/dist/site-roster.js +21 -4
  19. package/dist/spoke-checkout.d.ts +11 -3
  20. package/dist/spoke-checkout.js +62 -12
  21. package/dist/starter-grid.d.ts +1 -1
  22. package/dist/surfaces.d.ts +14 -0
  23. package/dist/surfaces.js +170 -1
  24. package/dist/template-manifest.d.ts +4 -3
  25. package/dist/topology.d.ts +7 -2
  26. package/dist/topology.js +88 -3
  27. package/package.json +5 -4
  28. package/templates/.env.example +3 -3
  29. package/templates/.github/workflows/ci.yml +5 -0
  30. package/templates/apps/api/.env.example +17 -1
  31. package/templates/apps/api/CLAUDE.md +31 -7
  32. package/templates/apps/api/LICENSE +6 -5
  33. package/templates/apps/api/NOTICE +2 -0
  34. package/templates/apps/api/jest.config.cjs +4 -29
  35. package/templates/apps/api/package.json +1 -0
  36. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
  37. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  38. package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -0
  39. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  40. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  41. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  42. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  43. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  44. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  45. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  46. package/templates/apps/api/src/lib/stripe.ts +176 -3
  47. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
  48. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
  49. package/templates/apps/api/src/middleware/auth.ts +214 -46
  50. package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
  51. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  52. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  53. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  54. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  55. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  56. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  57. package/templates/apps/api/src/routes/leads/index.ts +19 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  59. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  60. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  61. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  62. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  63. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  64. package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
  65. package/templates/apps/api/src/server.ts +218 -16
  66. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  67. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  68. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +173 -4
  69. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  70. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  71. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  72. package/templates/apps/api/src/services/consent.ts +488 -3
  73. package/templates/apps/api/src/services/lead-routing.ts +135 -14
  74. package/templates/apps/api/src/services/person.ts +501 -1
  75. package/templates/apps/api/src/services/project-listings.ts +7 -2
  76. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  77. package/templates/apps/api/src/services/revalidation.ts +11 -1
  78. package/templates/apps/api/vercel.json +2 -2
  79. package/templates/apps/mcp/.env.example +10 -0
  80. package/templates/apps/mcp/CLAUDE.md +9 -4
  81. package/templates/apps/mcp/LICENSE +6 -5
  82. package/templates/apps/mcp/NOTICE +2 -0
  83. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  84. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  85. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  86. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  87. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  88. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  89. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  90. package/templates/apps/mcp/jest.config.cjs +3 -44
  91. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  92. package/templates/apps/mcp/src/boot.ts +5 -23
  93. package/templates/apps/mcp/src/http-transport.ts +93 -73
  94. package/templates/apps/mcp/src/index.ts +2 -0
  95. package/templates/apps/mcp/src/request-context.ts +52 -0
  96. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  97. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  98. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  99. package/templates/apps/web/LICENSE +6 -5
  100. package/templates/apps/web/NOTICE +2 -0
  101. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  102. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  103. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +302 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
  106. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +232 -29
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  117. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  118. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
  119. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  120. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
  129. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  132. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  134. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  137. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
  138. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  139. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  141. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  142. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
  143. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  146. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  148. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  149. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
  150. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  151. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  152. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  153. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
  154. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  155. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  156. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  157. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  160. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  161. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  162. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  163. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  164. package/templates/apps/web/auth.ts +25 -2
  165. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  166. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  167. package/templates/apps/web/lib/account-config.ts +188 -52
  168. package/templates/apps/web/lib/auth-email.ts +45 -10
  169. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  170. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  171. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  172. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  173. package/templates/apps/web/lib/topology.generated.ts +33 -0
  174. package/templates/apps/web/messages/en.json +194 -33
  175. package/templates/database/CHANGELOG.md +37 -0
  176. package/templates/database/LICENSE +6 -5
  177. package/templates/database/NOTICE +2 -0
  178. package/templates/database/README.md +1 -1
  179. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  180. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  181. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  182. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  183. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  184. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  185. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  186. package/templates/database/__tests__/person.db.test.ts +3 -1
  187. package/templates/database/__tests__/seed.test.ts +54 -2
  188. package/templates/database/inbox/schema.prisma +166 -25
  189. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  190. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  191. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  192. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  193. package/templates/database/package.json +3 -2
  194. package/templates/database/seed.ts +34 -2
  195. package/versions.json +36 -36
  196. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  197. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  198. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  199. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  200. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  201. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  202. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  203. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  204. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  205. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  206. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  207. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  208. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  209. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  210. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  211. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  212. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  213. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  214. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  215. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  216. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  217. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  218. package/templates/database/__tests__/build.test.ts +0 -131
@@ -0,0 +1,172 @@
1
+ /**
2
+ * /[locale]/account/billing — User settings › Billing (#8339 D8; builds on
3
+ * #8232 D9, AC-MAA-19).
4
+ *
5
+ * RSC, outside the account layout's SignedInShell (#8430), in the member's
6
+ * four-tab settings frame (D7) — standalone, the Admin's shape. Lays out like the Admin's
7
+ * organization Billing page (`OrgBillingPanel`'s `current-plan` layout):
8
+ * the shared `CurrentPlanCard` (@working-theory/templates/account, spec
9
+ * 8339 D5), by `loadBilling()`'s answer (`billing-source.ts`, the billing
10
+ * topology seam):
11
+ *
12
+ * - a subscription — the plan (or `unnamedPlan`), the status plus when
13
+ * access ends (when set, C4 — `subscription.me` carries no price), the
14
+ * card on file, then Manage billing when a portal is available;
15
+ * - no subscription — the same card shape with the plain line and no
16
+ * button (C5);
17
+ * - a failed read — the area's error row with Retry, never a false "you
18
+ * have no subscription".
19
+ *
20
+ * After any of the three, one plain line when some of the member's billing
21
+ * is on a Stripe account this spoke's key cannot read (#8217 D12), then
22
+ * each section a module contributes (`accountBillingSections`, #8231 D4).
23
+ */
24
+
25
+ import { AccountSettingsFrame, AccountSettingsPane, CurrentPlanCard } from "@working-theory/templates/account";
26
+ import { Button, Text } from "@working-theory/ui/primitives";
27
+ import type { Metadata } from "next";
28
+ import { getTranslations } from "next-intl/server";
29
+ import type { ReactElement } from "react";
30
+
31
+ import { accountBillingSections } from "../../../../../lib/account-config";
32
+ import { accountSession } from "../account-data";
33
+ import { openBillingPortal } from "../actions";
34
+ import { type BillingState, loadBilling } from "../billing-source";
35
+ import { ErrorRow } from "../error-row";
36
+ import { memberSettingsFrame, SettingsSection, type Translate as MemberTranslate } from "../settings-frame";
37
+
38
+ interface Props {
39
+ params: Promise<{ locale: string }>;
40
+ }
41
+
42
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
43
+ const { locale } = await params;
44
+ const t = await getTranslations({ locale, namespace: "account" });
45
+ return { title: t("settings.billing.title"), description: t("settings.billing.lead") };
46
+ }
47
+
48
+ /** The statuses with a message key; any other renders as the API sent it. */
49
+ const KNOWN_STATUSES: ReadonlySet<string> = new Set(["trialing", "active", "past_due", "canceled"]);
50
+
51
+ type Translate = Awaited<ReturnType<typeof getTranslations<"account">>>;
52
+
53
+ function Subscription({
54
+ state,
55
+ locale,
56
+ t,
57
+ }: {
58
+ state: Extract<BillingState, { kind: "subscription" }>;
59
+ locale: string;
60
+ t: Translate;
61
+ }): ReactElement {
62
+ const card = state.paymentMethod;
63
+ const statusLabel = KNOWN_STATUSES.has(state.status)
64
+ ? t(`settings.billing.statuses.${state.status}`)
65
+ : state.status;
66
+ const line = (
67
+ <span data-testid="account-billing-status">
68
+ {state.accessRevokesAt
69
+ ? `${statusLabel} · ${t("settings.billing.accessEnds", {
70
+ date: new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(
71
+ new Date(state.accessRevokesAt),
72
+ ),
73
+ })}`
74
+ : statusLabel}
75
+ </span>
76
+ );
77
+ const caption = (
78
+ <span data-testid="account-billing-payment">
79
+ {card !== null
80
+ ? t("settings.billing.card", {
81
+ brand: card.brand,
82
+ last4: card.last4,
83
+ month: String(card.expMonth).padStart(2, "0"),
84
+ year: String(card.expYear),
85
+ })
86
+ : t("settings.billing.noCard")}
87
+ </span>
88
+ );
89
+ return (
90
+ <CurrentPlanCard
91
+ title={t("settings.billing.currentPlan")}
92
+ heading={
93
+ <span data-testid="account-billing-plan">
94
+ {state.planName ?? t("settings.billing.unnamedPlan")}
95
+ </span>
96
+ }
97
+ line={line}
98
+ caption={caption}
99
+ action={
100
+ state.portalAvailable ? (
101
+ <form action={openBillingPortal}>
102
+ <Button type="submit" variant="outline" data-testid="account-billing-manage">
103
+ {t("settings.billing.manage")}
104
+ </Button>
105
+ </form>
106
+ ) : undefined
107
+ }
108
+ testId="account-billing"
109
+ cardTestId="account-billing-card"
110
+ />
111
+ );
112
+ }
113
+
114
+ export default async function AccountBillingPage({ params }: Props): Promise<ReactElement> {
115
+ const { locale } = await params;
116
+ const t = await getTranslations({ locale, namespace: "account" });
117
+ const session = await accountSession(locale);
118
+ const frame = memberSettingsFrame(locale, t as unknown as MemberTranslate, "billing");
119
+
120
+ // Unreachable behind the (authed) gate; a session that expired between
121
+ // the layout and the page renders the pane's title alone.
122
+ if (!session) {
123
+ return (
124
+ <AccountSettingsFrame {...frame} name="">
125
+ <AccountSettingsPane id="account-settings-billing" title={t("settings.billing.title")} lead="">
126
+ {null}
127
+ </AccountSettingsPane>
128
+ </AccountSettingsFrame>
129
+ );
130
+ }
131
+
132
+ const { ctx } = session;
133
+ const state = await loadBilling(ctx.init);
134
+ const name = ctx.user.name || ctx.user.email;
135
+
136
+ return (
137
+ <AccountSettingsFrame {...frame} name={name}>
138
+ <AccountSettingsPane
139
+ id="account-settings-billing"
140
+ title={t("settings.billing.title")}
141
+ lead={t("settings.billing.lead")}
142
+ >
143
+ {state.kind === "subscription" ? (
144
+ <Subscription state={state} locale={locale} t={t} />
145
+ ) : state.kind === "none" ? (
146
+ <CurrentPlanCard
147
+ title={t("settings.billing.currentPlan")}
148
+ line={<span data-testid="account-billing-none">{t("settings.billing.none")}</span>}
149
+ testId="account-billing"
150
+ cardTestId="account-billing-card"
151
+ />
152
+ ) : (
153
+ <ErrorRow message={t("settings.billing.unavailable")} retryLabel={t("inbox.retry")} />
154
+ )}
155
+
156
+ {state.kind !== "failed" && state.unreadable ? (
157
+ <div data-testid="account-billing-unreadable">
158
+ <Text as="p" variant="body-sm" tone="muted">
159
+ {t("settings.billing.unreadable")}
160
+ </Text>
161
+ </div>
162
+ ) : null}
163
+
164
+ {accountBillingSections.map((section) => (
165
+ <SettingsSection key={section.id} id={section.id} title={ctx.t(section.title)}>
166
+ {section.render(ctx)}
167
+ </SettingsSection>
168
+ ))}
169
+ </AccountSettingsPane>
170
+ </AccountSettingsFrame>
171
+ );
172
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * account/billing-source.ts (server code) — what User settings › Billing
3
+ * shows (#8232, spec docs/specs/8232-member-settings-account-general-and-spec.md
4
+ * D9).
5
+ *
6
+ * One of the account tree's TWO topology seams (roadmap D1; the other is
7
+ * `sign-in-method.ts`): no other file under the account tree tests
8
+ * `SIGN_IN_PROVIDER`, `TOPOLOGY` or `SIGN_IN_ENABLED`.
9
+ *
10
+ * - On a spoke that signs in through its hub, the spoke's own billing
11
+ * (#8217 D10): `loadSpokeBilling()` from the generated seam, which
12
+ * reaches the rendered `billing/spoke-billing.action.ts` on a spoke that
13
+ * carries commerce and resolves `null` on one that does not — then
14
+ * `none` with no call. The hub admits no subscription read for a hub credential
15
+ * (roadmap D11), and this file imports nothing from the checkout package:
16
+ * the action reads the vouched customer ids itself.
17
+ * - Otherwise `subscription.me(init, { include: "billing" })` — the plan
18
+ * name and the card summary are #8171's additive fields:
19
+ * - 401, 403 or 404 (the commerce-disabled build answers 404), or a
20
+ * 2xx whose `status` is `null` ⇒ `none` — the caller has no
21
+ * subscription (C9);
22
+ * - any other non-2xx, or a throw ⇒ `failed`, logged with the status
23
+ * or `err.name` — the page shows the error row with Retry, never a
24
+ * false "you have no subscription";
25
+ * - otherwise `subscription`, absent optional fields read as `null`.
26
+ *
27
+ * `billingPortalUrl()` is the one place a portal URL is minted, for
28
+ * `openBillingPortal` (`actions.ts`): the spoke's own key through the same
29
+ * loader on a hub-mode spoke, else the hub's portal route. `null` means no
30
+ * portal — the action then redirects nowhere.
31
+ */
32
+
33
+ import {
34
+ subscription,
35
+ type SubscriptionLifecycleStatus,
36
+ type SubscriptionPaymentMethod,
37
+ } from "@working-theory/api-client";
38
+
39
+ import { forwardedCookieInit } from "../../../../lib/server-api";
40
+ import {
41
+ SIGN_IN_PROVIDER,
42
+ type SpokeBillingRead,
43
+ loadSpokeBilling,
44
+ } from "../../../../lib/topology.generated";
45
+
46
+ import { logReadFailure } from "./account-data";
47
+
48
+ export type BillingState =
49
+ | {
50
+ kind: "subscription";
51
+ status: SubscriptionLifecycleStatus;
52
+ planName: string | null;
53
+ paymentMethod: SubscriptionPaymentMethod | null;
54
+ accessRevokesAt: string | null;
55
+ portalAvailable: boolean;
56
+ /** Some vouched billing is on a Stripe account this spoke's key cannot read (#8217 D12). */
57
+ unreadable: boolean;
58
+ }
59
+ | { kind: "none"; unreadable: boolean }
60
+ | { kind: "failed" };
61
+
62
+ /** The statuses that mean "this caller has no subscription to show" (C9). */
63
+ const NO_SUBSCRIPTION_STATUSES: ReadonlySet<number> = new Set([401, 403, 404]);
64
+
65
+ /** PII-safe error identity for logs (the `checkout.actions.ts` idiom: `err.name` only). */
66
+ function errorName(err: unknown): string {
67
+ return err instanceof Error ? err.name : "unknown";
68
+ }
69
+
70
+ /** A hub-mode spoke's Billing: the rendered action through the seam, else none (#8217 D10). */
71
+ async function loadSpokeBillingState(): Promise<BillingState> {
72
+ let read: SpokeBillingRead | null;
73
+ try {
74
+ const billing = await loadSpokeBilling();
75
+ read = billing === null ? null : await billing.read();
76
+ } catch (err) {
77
+ logReadFailure("spoke-billing", err);
78
+ return { kind: "failed" };
79
+ }
80
+ if (read === null) return { kind: "none", unreadable: false };
81
+ if (read.kind !== "subscription") return read;
82
+ return {
83
+ kind: "subscription",
84
+ status: read.status,
85
+ planName: read.planName,
86
+ paymentMethod: read.paymentMethod,
87
+ accessRevokesAt: null,
88
+ portalAvailable: true,
89
+ unreadable: read.unreadable,
90
+ };
91
+ }
92
+
93
+ export async function loadBilling(init: { headers: Record<string, string> }): Promise<BillingState> {
94
+ if (SIGN_IN_PROVIDER === "hub") return loadSpokeBillingState();
95
+ try {
96
+ const res = await subscription.me(init, { include: "billing" });
97
+ if (!res.ok) {
98
+ if (NO_SUBSCRIPTION_STATUSES.has(res.status)) return { kind: "none", unreadable: false };
99
+ console.error(`[account] read failed source=billing status=${res.status}`);
100
+ return { kind: "failed" };
101
+ }
102
+ const sub = res.body;
103
+ if (sub.status === null) return { kind: "none", unreadable: false };
104
+ return {
105
+ kind: "subscription",
106
+ status: sub.status,
107
+ planName: sub.planName ?? null,
108
+ paymentMethod: sub.paymentMethod ?? null,
109
+ accessRevokesAt: sub.accessRevokesAt,
110
+ portalAvailable: sub.portalAvailable,
111
+ unreadable: false,
112
+ };
113
+ } catch (err) {
114
+ logReadFailure("billing", err);
115
+ return { kind: "failed" };
116
+ }
117
+ }
118
+
119
+ /**
120
+ * The caller's Stripe billing-portal URL, or `null` for no portal (#8217
121
+ * D10). Minted on a Manage click, never on render: on a hub-mode spoke from
122
+ * the spoke's own key (a loader that fails to load is `null`), otherwise from
123
+ * the hub's portal route — `null` on a non-2xx or a throw, logged with the
124
+ * status or `err.name` only.
125
+ */
126
+ export async function billingPortalUrl(): Promise<string | null> {
127
+ if (SIGN_IN_PROVIDER === "hub") {
128
+ try {
129
+ return (await (await loadSpokeBilling())?.portalUrl()) ?? null;
130
+ } catch (err) {
131
+ console.error(`[openBillingPortal] source=spoke-billing errName=${errorName(err)}`);
132
+ return null;
133
+ }
134
+ }
135
+ const init = await forwardedCookieInit();
136
+ try {
137
+ const res = await subscription.portalSession(init);
138
+ if (!res.ok) {
139
+ console.error(`[openBillingPortal] status=${res.status}`);
140
+ return null;
141
+ }
142
+ return res.body.url;
143
+ } catch (err) {
144
+ console.error(`[openBillingPortal] errName=${errorName(err)}`);
145
+ return null;
146
+ }
147
+ }
@@ -0,0 +1,229 @@
1
+ "use client"; // reads the request form on open and posts a controlled form
2
+
3
+ /**
4
+ * RequestQuote — the New menu's Request quote popup (#8233, spec D5, D7,
5
+ * D10; roadmap D5). It renders the request-quote magnet's CURRENT steps:
6
+ * the form is read each time the popup opens (`readRequestForm`), so a step
7
+ * the provider adds in the Admin shows on the next open. `form: null` —
8
+ * Request quote switched off since the menu loaded — says so, with no form.
9
+ *
10
+ * Every field of every served step renders in order (the server checks
11
+ * every one, and evaluates no `showIf`), its copy resolved from the
12
+ * provider's `LocalizedString`s and rendered as text, never markup. Only
13
+ * the six input types the server answers render; a step title is a heading
14
+ * only when there is more than one step. Each control posts as
15
+ * `answer.<name>`; a blank optional answer is omitted by the action. No
16
+ * identity field is added — the server takes the name and email from the
17
+ * session; a field shows only because the provider's form asks it.
18
+ *
19
+ * Controlled, posting what the state holds, so a refusal keeps every answer.
20
+ * The link is sent as typed (`inputMode="url"`, no browser URL check): the
21
+ * server's https rule is the only one.
22
+ */
23
+
24
+ import {
25
+ Button,
26
+ Heading,
27
+ Input,
28
+ Label,
29
+ Select,
30
+ SelectContent,
31
+ SelectItem,
32
+ SelectTrigger,
33
+ SelectValue,
34
+ Text,
35
+ Textarea,
36
+ } from "@working-theory/ui/primitives";
37
+ import {
38
+ resolveLocalizedString,
39
+ type MemberRequestFormResponse,
40
+ } from "@working-theory/validation";
41
+ import {
42
+ startTransition,
43
+ useActionState,
44
+ useId,
45
+ useState,
46
+ type FormEvent,
47
+ type ReactElement,
48
+ } from "react";
49
+
50
+ import { readRequestForm, requestQuote, type NewActionState } from "../new-actions";
51
+ import {
52
+ NewPopupRefusal,
53
+ NewPopupStatus,
54
+ useNewMenuPopup,
55
+ useOpenRead,
56
+ type NewPopupLabels,
57
+ } from "../new-menu";
58
+
59
+ type RequestForm = NonNullable<MemberRequestFormResponse["form"]>;
60
+ type FormField = RequestForm["steps"][number]["fields"][number];
61
+
62
+ /** The input types the requests write answers (`ANSWERABLE`, 8170). */
63
+ const RENDERED = new Set(["text", "email", "tel", "textarea", "select", "url"]);
64
+
65
+ export interface RequestQuoteLabels extends NewPopupLabels {
66
+ submit: string;
67
+ }
68
+
69
+ export interface RequestQuoteProps {
70
+ locale: string;
71
+ labels: RequestQuoteLabels;
72
+ }
73
+
74
+ export function RequestQuote({ locale, labels }: RequestQuoteProps): ReactElement {
75
+ const [read, retry] = useOpenRead(readRequestForm);
76
+ if (read === "loading" || read.kind === "error") {
77
+ return <NewPopupStatus failed={read !== "loading"} labels={labels} onRetry={retry} />;
78
+ }
79
+ if (read.form === null) {
80
+ return (
81
+ <Text as="p" variant="body-sm" data-testid="new-quote-off">
82
+ {labels.errors.request_form_off}
83
+ </Text>
84
+ );
85
+ }
86
+ return <RequestQuoteForm form={read.form} locale={locale} labels={labels} />;
87
+ }
88
+
89
+ function RequestQuoteForm({
90
+ form,
91
+ locale,
92
+ labels,
93
+ }: {
94
+ form: RequestForm;
95
+ locale: string;
96
+ labels: RequestQuoteLabels;
97
+ }): ReactElement {
98
+ const popup = useNewMenuPopup();
99
+ const id = useId();
100
+ const [answers, setAnswers] = useState<Record<string, string>>({});
101
+ const [state, dispatch, pending] = useActionState<NewActionState, FormData>(
102
+ async (prev, formData) => {
103
+ const next = await requestQuote(prev, formData);
104
+ if (next.kind === "success") popup.close(true);
105
+ return next;
106
+ },
107
+ { kind: "idle" },
108
+ );
109
+
110
+ const text = (value: Record<string, string>): string =>
111
+ resolveLocalizedString(value, locale, form.defaultLocale);
112
+ const setAnswer = (name: string, value: string): void =>
113
+ setAnswers((current) => ({ ...current, [name]: value }));
114
+
115
+ const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
116
+ event.preventDefault();
117
+ const formData = new FormData();
118
+ for (const [name, value] of Object.entries(answers)) formData.set(`answer.${name}`, value);
119
+ startTransition(() => dispatch(formData));
120
+ };
121
+
122
+ const renderField = (field: FormField): ReactElement | null => {
123
+ if (!RENDERED.has(field.inputType)) return null;
124
+ const controlId = `${id}-${field.name}`;
125
+ const helpId = `${controlId}-help`;
126
+ const help = field.copy.help ? text(field.copy.help) : null;
127
+ const value = answers[field.name] ?? "";
128
+ const common = {
129
+ id: controlId,
130
+ name: `answer.${field.name}`,
131
+ required: field.required === true,
132
+ ...(help ? { "aria-describedby": helpId } : {}),
133
+ "data-testid": `new-quote-field-${field.name}`,
134
+ };
135
+ const placeholder = field.copy.placeholder ? text(field.copy.placeholder) : undefined;
136
+ const onChange = (event: { target: { value: string } }): void =>
137
+ setAnswer(field.name, event.target.value);
138
+
139
+ let control: ReactElement;
140
+ if (field.inputType === "textarea") {
141
+ control = (
142
+ <Textarea {...common} rows={4} value={value} placeholder={placeholder} onChange={onChange} />
143
+ );
144
+ } else if (field.inputType === "select") {
145
+ control = (
146
+ <Select
147
+ name={common.name}
148
+ required={common.required}
149
+ value={value}
150
+ onValueChange={(next) => setAnswer(field.name, next)}
151
+ >
152
+ <SelectTrigger
153
+ id={controlId}
154
+ {...(help ? { "aria-describedby": helpId } : {})}
155
+ data-testid={common["data-testid"]}
156
+ >
157
+ <SelectValue {...(placeholder ? { placeholder } : {})} />
158
+ </SelectTrigger>
159
+ <SelectContent>
160
+ {(field.options ?? []).map((option) => (
161
+ <SelectItem key={option.value} value={option.value}>
162
+ {text(option.label)}
163
+ </SelectItem>
164
+ ))}
165
+ </SelectContent>
166
+ </Select>
167
+ );
168
+ } else {
169
+ control = (
170
+ <Input
171
+ {...common}
172
+ // A url field takes text: no browser URL check, so the server's plain refusal is the rule.
173
+ type={field.inputType === "url" ? "text" : field.inputType}
174
+ {...(field.inputType === "url" ? { inputMode: "url" as const } : {})}
175
+ value={value}
176
+ placeholder={placeholder}
177
+ onChange={onChange}
178
+ />
179
+ );
180
+ }
181
+
182
+ return (
183
+ <div key={field.name} className="flex flex-col gap-2">
184
+ <div className="flex items-baseline gap-2">
185
+ <Label htmlFor={controlId}>{text(field.copy.label)}</Label>
186
+ {field.required ? null : (
187
+ <Text as="span" variant="caption">
188
+ {labels.optional}
189
+ </Text>
190
+ )}
191
+ </div>
192
+ {control}
193
+ {help ? (
194
+ <Text as="p" id={helpId} variant="caption">
195
+ {help}
196
+ </Text>
197
+ ) : null}
198
+ </div>
199
+ );
200
+ };
201
+
202
+ const titled = form.steps.length > 1;
203
+ return (
204
+ <form onSubmit={onSubmit} className="flex flex-col gap-4" data-testid="new-quote-form">
205
+ {form.steps.map((step) => (
206
+ <div key={step.key} className="flex flex-col gap-4">
207
+ {titled ? (
208
+ <Heading level={3} visual={4} className="!text-body font-semibold">
209
+ {text(step.copy.title)}
210
+ </Heading>
211
+ ) : null}
212
+ {step.fields.map(renderField)}
213
+ </div>
214
+ ))}
215
+ {state.kind === "error" ? <NewPopupRefusal code={state.code} labels={labels} /> : null}
216
+ <div>
217
+ <Button
218
+ type="submit"
219
+ disabled={pending}
220
+ // 44px on a phone (a touch-first context); the 36px primary from md up.
221
+ className="max-md:min-h-11"
222
+ data-testid="new-quote-submit"
223
+ >
224
+ {pending ? labels.submitting : labels.submit}
225
+ </Button>
226
+ </div>
227
+ </form>
228
+ );
229
+ }