create-ailk 0.19.0 → 0.20.0

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 (197) hide show
  1. package/README.md +64 -7
  2. package/dist/add-module.js +7 -2
  3. package/dist/cli.js +35 -0
  4. package/dist/copy.d.ts +41 -0
  5. package/dist/copy.js +87 -21
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  9. package/dist/lib/fetch-template-snapshot.js +205 -0
  10. package/dist/lib/merge-three-way.d.ts +31 -0
  11. package/dist/lib/merge-three-way.js +65 -0
  12. package/dist/lib/scaffold-render-sweep.d.ts +23 -0
  13. package/dist/lib/scaffold-render-sweep.js +52 -0
  14. package/dist/module-architecture.js +64 -5
  15. package/dist/programmatic.d.ts +7 -1
  16. package/dist/programmatic.js +40 -3
  17. package/dist/site-roster.js +21 -4
  18. package/dist/spoke-checkout.d.ts +7 -0
  19. package/dist/spoke-checkout.js +8 -1
  20. package/dist/surfaces.d.ts +14 -0
  21. package/dist/surfaces.js +98 -3
  22. package/dist/template-record.d.ts +59 -0
  23. package/dist/template-record.js +183 -0
  24. package/dist/topology.js +29 -1
  25. package/dist/update.d.ts +48 -0
  26. package/dist/update.js +504 -0
  27. package/package.json +3 -3
  28. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  29. package/templates/.claude/hooks/architecture-check.sh +8 -0
  30. package/templates/.claude/rules/loop-contract.md +129 -0
  31. package/templates/.claude/settings.json +3 -2
  32. package/templates/.env.example +13 -0
  33. package/templates/CLAUDE.md +1 -1
  34. package/templates/CONVENTIONS.md +3 -2
  35. package/templates/_gitignore +162 -0
  36. package/templates/apps/api/.env.example +17 -1
  37. package/templates/apps/api/CLAUDE.md +18 -8
  38. package/templates/apps/api/package.json +2 -1
  39. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
  40. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  41. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  42. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  43. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  44. package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
  45. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  46. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  47. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  48. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  49. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  50. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  51. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  52. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  53. package/templates/apps/api/src/lib/stripe.ts +97 -5
  54. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
  55. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  56. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  57. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  58. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
  59. package/templates/apps/api/src/middleware/auth.ts +16 -4
  60. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  61. package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
  62. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  63. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  64. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  65. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  67. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  68. package/templates/apps/api/src/routes/leads/index.ts +15 -0
  69. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  70. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  71. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  73. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  74. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  75. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  76. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  77. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  78. package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
  79. package/templates/apps/api/src/server.ts +125 -18
  80. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  81. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  82. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  83. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
  84. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  85. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  86. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  87. package/templates/apps/api/src/services/audit-log.ts +72 -10
  88. package/templates/apps/api/src/services/consent.ts +488 -3
  89. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  90. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  91. package/templates/apps/api/src/services/lead-routing.ts +105 -12
  92. package/templates/apps/api/src/services/person.ts +501 -1
  93. package/templates/apps/api/src/services/project-listings.ts +7 -2
  94. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  95. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  96. package/templates/apps/api/src/services/revalidation.ts +11 -1
  97. package/templates/apps/api/vercel.json +1 -1
  98. package/templates/apps/mcp/CLAUDE.md +19 -7
  99. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  100. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  101. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  102. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  103. package/templates/apps/web/_gitignore +6 -0
  104. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  105. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  106. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  107. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
  117. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
  120. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
  122. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  129. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  132. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
  134. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
  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/page.tsx +70 -27
  137. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
  138. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
  139. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  141. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  142. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  143. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
  146. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  148. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
  149. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
  150. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  151. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  152. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  153. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  154. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  155. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  156. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  157. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  158. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  159. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  160. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  161. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  162. package/templates/apps/web/auth.ts +20 -1
  163. package/templates/apps/web/jest.config.cjs +3 -0
  164. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  165. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  166. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  167. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  168. package/templates/apps/web/lib/account-config.ts +31 -16
  169. package/templates/apps/web/lib/auth-email.ts +45 -10
  170. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  171. package/templates/apps/web/lib/relay.ts +20 -0
  172. package/templates/apps/web/lib/topology.generated.ts +9 -0
  173. package/templates/apps/web/messages/en.json +121 -18
  174. package/templates/apps/web/next.config.mjs +8 -2
  175. package/templates/database/CHANGELOG.md +55 -0
  176. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  177. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  178. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  179. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  180. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  181. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  182. package/templates/database/__tests__/person.db.test.ts +3 -1
  183. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  184. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  185. package/templates/database/_gitignore +8 -0
  186. package/templates/database/inbox/schema.prisma +114 -22
  187. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  188. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  189. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  190. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  191. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  192. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  193. package/templates/database/ops/schema.prisma +16 -0
  194. package/templates/database/package.json +1 -1
  195. package/templates/project.yaml +6 -3
  196. package/versions.json +35 -35
  197. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -19,7 +19,14 @@
19
19
  * https; no deliverer is shown;
20
20
  * - the review form shows on the latest version only, only while the
21
21
  * artifact is not approved and that version has no review; after a
22
- * Request changes the page says the next version will appear there.
22
+ * Request changes the page says the next version will appear there;
23
+ * - a proposal (#8516, spec
24
+ * docs/specs/8516-proposals-as-an-artifact-kind-spec.md D19, AC-3, AC-4)
25
+ * reads "Proposal" and its price, answers "Your decision on version <n>"
26
+ * with Approve proposal / Request changes, and in the latest version's
27
+ * place shows Pay now while payment is due, "Paid <date>" once paid, and
28
+ * the pending line on `?paid=1` before the webhook lands — while a
29
+ * deliverable renders exactly as before.
23
30
  */
24
31
 
25
32
  import { fireEvent, render, screen, within } from "@testing-library/react";
@@ -205,9 +212,15 @@ async function renderList(): Promise<void> {
205
212
  render(await ListPage({ params: Promise.resolve({ locale: "en" }) }));
206
213
  }
207
214
 
208
- async function renderArtifact(pieceId: string): Promise<void> {
215
+ async function renderArtifact(
216
+ pieceId: string,
217
+ searchParams?: Record<string, string>,
218
+ ): Promise<void> {
209
219
  render(
210
- await ArtifactPage({ params: Promise.resolve({ locale: "en", pieceId }) }),
220
+ await ArtifactPage({
221
+ params: Promise.resolve({ locale: "en", pieceId }),
222
+ ...(searchParams ? { searchParams: Promise.resolve(searchParams) } : {}),
223
+ }),
211
224
  );
212
225
  }
213
226
 
@@ -512,3 +525,163 @@ describe("the review form's place (D4, C3, AC-4)", () => {
512
525
  );
513
526
  });
514
527
  });
528
+
529
+ // #8516 D19 — a priced proposal, unreviewed, as apps/api serves it.
530
+ const PROPOSAL_ID = "piece_01920000-0000-7000-8000-00000000000d";
531
+ const PROPOSAL = {
532
+ id: PROPOSAL_ID,
533
+ title: "Site redesign",
534
+ approvedAt: null,
535
+ kind: "proposal",
536
+ price: { amount: 120000, currency: "usd" },
537
+ payment: null,
538
+ versions: [version({ version: 1, deliveredAt: "2026-10-01T12:00:00.000Z" })],
539
+ };
540
+ const APPROVED_AT = "2026-10-05T09:00:00.000Z";
541
+ const PAID_AT = "2026-10-06T09:30:00.000Z";
542
+
543
+ function approvedProposal(payment: unknown) {
544
+ const [latest] = PROPOSAL.versions;
545
+ return {
546
+ ...PROPOSAL,
547
+ approvedAt: APPROVED_AT,
548
+ payment,
549
+ versions: [{ ...latest!, review: "approved", reviewedAt: APPROVED_AT }],
550
+ };
551
+ }
552
+
553
+ // An older hub serves no `kind`, `price` or `payment` (#8516 D4: optional).
554
+ const ALPHA_WITHOUT_KIND = {
555
+ id: ID.mango,
556
+ title: "Mango brief",
557
+ approvedAt: null,
558
+ versions: [version({ version: 1 })],
559
+ };
560
+
561
+ const PAY_IDS = ["artifact-pay-form", "artifact-pay", "artifact-paid-pending"];
562
+
563
+ describe("a proposal (#8516 D19, AC-3, AC-4)", () => {
564
+ it("renders Proposal, its price, the decision heading and Approve proposal / Request changes", async () => {
565
+ mockArtifacts.mockResolvedValue(ok([PROPOSAL]));
566
+ await renderArtifact(PROPOSAL_ID);
567
+ const page = screen.getByTestId("artifact");
568
+ expect(within(page).getByText("Proposal")).toBeInTheDocument();
569
+ expect(within(page).queryByText("Artifact")).toBeNull();
570
+ expect(screen.getByTestId("artifact-price")).toHaveTextContent(
571
+ "Price $1,200.00",
572
+ );
573
+ const v1 = screen.getByTestId("artifact-version-1");
574
+ expect(within(v1).getByRole("heading", { level: 3 })).toHaveTextContent(
575
+ "Your decision on version 1",
576
+ );
577
+ expect(within(v1).getByTestId("artifact-review-approve")).toHaveTextContent(
578
+ "Approve proposal",
579
+ );
580
+ expect(
581
+ within(v1).getByTestId("artifact-review-request-changes"),
582
+ ).toHaveTextContent("Request changes");
583
+ for (const id of PAY_IDS) expect(screen.queryByTestId(id)).toBeNull();
584
+ expect(screen.queryByTestId("artifact-paid")).toBeNull();
585
+ });
586
+
587
+ it("a currency Intl cannot format falls back to the amount and code instead of erroring the page", async () => {
588
+ mockArtifacts.mockResolvedValue(
589
+ ok([{ ...PROPOSAL, price: { amount: 120000, currency: "u$" } }]),
590
+ );
591
+ await renderArtifact(PROPOSAL_ID);
592
+ expect(screen.getByTestId("artifact-price")).toHaveTextContent("Price 1200 U$");
593
+ });
594
+
595
+ it("a deliverable still renders Artifact, no price and Approve", async () => {
596
+ mockArtifacts.mockResolvedValue(
597
+ ok([{ ...VERSIONED, kind: "deliverable", price: null, payment: null }]),
598
+ );
599
+ await renderArtifact(ID.alpha);
600
+ const page = screen.getByTestId("artifact");
601
+ expect(within(page).getByText("Artifact")).toBeInTheDocument();
602
+ expect(within(page).queryByText("Proposal")).toBeNull();
603
+ expect(screen.queryByTestId("artifact-price")).toBeNull();
604
+ expect(page.textContent).not.toMatch(/Price/);
605
+ const v3 = screen.getByTestId("artifact-version-3");
606
+ expect(within(v3).getByRole("heading", { level: 3 })).toHaveTextContent(
607
+ "Review version 3",
608
+ );
609
+ expect(within(v3).getByTestId("artifact-review-approve")).toHaveTextContent(
610
+ /^Approve$/,
611
+ );
612
+ for (const id of PAY_IDS) expect(screen.queryByTestId(id)).toBeNull();
613
+ });
614
+
615
+ it("an older hub's artifact with no kind renders as a deliverable", async () => {
616
+ mockArtifacts.mockResolvedValue(ok([ALPHA_WITHOUT_KIND]));
617
+ await renderArtifact(ID.mango);
618
+ expect(
619
+ within(screen.getByTestId("artifact")).getByText("Artifact"),
620
+ ).toBeInTheDocument();
621
+ expect(screen.queryByTestId("artifact-price")).toBeNull();
622
+ expect(screen.getByTestId("artifact-review-approve")).toHaveTextContent(
623
+ /^Approve$/,
624
+ );
625
+ });
626
+
627
+ it("while payment is due, Pay now sits in the latest version's place and the review form is gone", async () => {
628
+ mockArtifacts.mockResolvedValue(ok([approvedProposal({ status: "due" })]));
629
+ await renderArtifact(PROPOSAL_ID);
630
+ const v1 = screen.getByTestId("artifact-version-1");
631
+ expect(within(v1).getByTestId("artifact-pay")).toHaveTextContent("Pay now");
632
+ expect(screen.getAllByTestId("artifact-pay")).toHaveLength(1);
633
+ expect(screen.queryByTestId("artifact-review-approve")).toBeNull();
634
+ expect(screen.queryByTestId("artifact-paid")).toBeNull();
635
+ expect(screen.queryByTestId("artifact-paid-pending")).toBeNull();
636
+ expect(screen.getByTestId("artifact-status")).toHaveTextContent("Approved");
637
+ });
638
+
639
+ it("once paid, the latest version says Paid <date> and offers no Pay now", async () => {
640
+ mockArtifacts.mockResolvedValue(
641
+ ok([approvedProposal({ status: "paid", paidAt: PAID_AT })]),
642
+ );
643
+ await renderArtifact(PROPOSAL_ID, { paid: "1" });
644
+ const v1 = screen.getByTestId("artifact-version-1");
645
+ expect(within(v1).getByTestId("artifact-paid")).toHaveTextContent(
646
+ `Paid ${medium(PAID_AT)}`,
647
+ );
648
+ for (const id of PAY_IDS) expect(screen.queryByTestId(id)).toBeNull();
649
+ });
650
+
651
+ it("back from Stripe (?paid=1) before the webhook lands, it says the payment was received instead of Pay now", async () => {
652
+ mockArtifacts.mockResolvedValue(ok([approvedProposal({ status: "due" })]));
653
+ await renderArtifact(PROPOSAL_ID, { paid: "1" });
654
+ const v1 = screen.getByTestId("artifact-version-1");
655
+ expect(within(v1).getByTestId("artifact-paid-pending")).toHaveTextContent(
656
+ "Payment received — it may take a minute to show here.",
657
+ );
658
+ expect(screen.queryByTestId("artifact-pay")).toBeNull();
659
+ expect(screen.queryByTestId("artifact-paid")).toBeNull();
660
+ });
661
+
662
+ it("an approved proposal with no payment surface (payment null) shows neither Pay now nor Paid", async () => {
663
+ mockArtifacts.mockResolvedValue(ok([approvedProposal(null)]));
664
+ await renderArtifact(PROPOSAL_ID, { paid: "1" });
665
+ for (const id of PAY_IDS) expect(screen.queryByTestId(id)).toBeNull();
666
+ expect(screen.queryByTestId("artifact-paid")).toBeNull();
667
+ expect(screen.getByTestId("artifact-price")).toHaveTextContent(
668
+ "Price $1,200.00",
669
+ );
670
+ });
671
+
672
+ it("the list reads Proposal · Version <n> in a proposal's caption and Latest cell, and a deliverable's as before", async () => {
673
+ mockArtifacts.mockResolvedValue(ok([PROPOSAL, ALPHA]));
674
+ await renderList();
675
+ const captions = screen.getAllByTestId("artifacts-row");
676
+ expect(captions.map((c) => c.textContent)).toEqual([
677
+ expect.stringContaining("Proposal · Version 1 · Ready for review"),
678
+ expect.stringContaining("Version 1 · Ready for review"),
679
+ ]);
680
+ expect(captions[1]!.textContent).not.toContain("Proposal");
681
+ const row = screen.getByTestId(`artifacts-table-row-${PROPOSAL_ID}`);
682
+ expect(row).toHaveTextContent("Proposal · Version 1");
683
+ expect(
684
+ screen.getByTestId(`artifacts-table-row-${ID.alpha}`),
685
+ ).not.toHaveTextContent("Proposal");
686
+ });
687
+ });
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Pay now on an approved proposal (#8516, spec
3
+ * docs/specs/8516-proposals-as-an-artifact-kind-spec.md D19, AC-4, AC-7).
4
+ *
5
+ * Two layers:
6
+ * - `payForProposal` (the Server Action), with the api-client's
7
+ * `member.payArtifact` as the one mocked boundary: it asks once with the
8
+ * caller's init and redirects only to a Stripe Checkout URL; a refusal, a
9
+ * throw or a URL on any other host is `payment_failed` with no redirect,
10
+ * and a log line carries `err.name` only — never the URL or the id.
11
+ * - `PayForm` (the client form): one button and no input of any kind — no
12
+ * card, number or expiry field; payment happens on Stripe's page.
13
+ */
14
+
15
+ import {
16
+ act,
17
+ fireEvent,
18
+ render,
19
+ screen,
20
+ waitFor,
21
+ } from "@testing-library/react";
22
+
23
+ const mockPayArtifact = jest.fn();
24
+ jest.mock("@working-theory/api-client", () => ({
25
+ __esModule: true,
26
+ member: {
27
+ reviewArtifact: jest.fn(),
28
+ payArtifact: (...args: unknown[]) => mockPayArtifact(...args),
29
+ },
30
+ }));
31
+
32
+ jest.mock("next/headers", () => ({
33
+ __esModule: true,
34
+ headers: jest
35
+ .fn()
36
+ .mockResolvedValue(new Headers({ origin: "https://site.example.com" })),
37
+ cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
38
+ }));
39
+
40
+ // Next's `redirect()` throws NEXT_REDIRECT; the mock does too, so a catch
41
+ // that swallowed it would show up as a resolved action.
42
+ const mockRedirect = jest.fn((url: string): never => {
43
+ throw new Error(`NEXT_REDIRECT ${url}`);
44
+ });
45
+ jest.mock("next/navigation", () => ({
46
+ __esModule: true,
47
+ useRouter: () => ({ push: jest.fn(), refresh: jest.fn() }),
48
+ redirect: (url: string) => mockRedirect(url),
49
+ }));
50
+
51
+ import messages from "../../../../../../messages/en.json";
52
+ import { PayForm, type PayFormLabels } from "../[pieceId]/pay-form";
53
+ import * as actions from "../artifact-actions";
54
+ import { payForProposal } from "../artifact-actions";
55
+
56
+ const PIECE = "piece_01920000-0000-7000-8000-00000000000d";
57
+ const INIT = {
58
+ headers: { cookie: "session=abc", origin: "https://site.example.com" },
59
+ };
60
+ const CHECKOUT = "https://checkout.stripe.com/c/pay/cs_test_x";
61
+ const p = messages.account.artifacts.proposal;
62
+ const LABELS: PayFormLabels = {
63
+ pay: p.pay,
64
+ paying: p.paying,
65
+ error: p.payError,
66
+ };
67
+
68
+ function pay(pieceId = PIECE) {
69
+ const data = new FormData();
70
+ data.set("pieceId", pieceId);
71
+ return payForProposal({ kind: "idle" }, data);
72
+ }
73
+
74
+ function refusal(status: number, code: string) {
75
+ return {
76
+ ok: false,
77
+ status,
78
+ body: { ok: false, error: { code, message: "x" } },
79
+ };
80
+ }
81
+
82
+ let errorSpy: jest.SpyInstance;
83
+
84
+ beforeEach(() => {
85
+ mockPayArtifact
86
+ .mockReset()
87
+ .mockResolvedValue({ ok: true, body: { url: CHECKOUT } });
88
+ mockRedirect.mockClear();
89
+ errorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined);
90
+ });
91
+
92
+ afterEach(() => {
93
+ errorSpy.mockRestore();
94
+ });
95
+
96
+ describe("payForProposal (D19, AC-4, AC-7)", () => {
97
+ it("calls member.payArtifact once with the caller's init and redirects to the Checkout URL", async () => {
98
+ await expect(pay()).rejects.toThrow("NEXT_REDIRECT");
99
+ expect(mockPayArtifact).toHaveBeenCalledTimes(1);
100
+ expect(mockPayArtifact).toHaveBeenCalledWith(PIECE, INIT);
101
+ expect(mockRedirect).toHaveBeenCalledTimes(1);
102
+ expect(mockRedirect).toHaveBeenCalledWith(CHECKOUT);
103
+ });
104
+
105
+ it.each([
106
+ ["a 409 proposal_paid refusal", refusal(409, "proposal_paid")],
107
+ ["a 404 (no payment surface)", refusal(404, "not_found")],
108
+ ["a 502 payment_unavailable", refusal(502, "payment_unavailable")],
109
+ [
110
+ "a URL on another host",
111
+ { ok: true, body: { url: "https://evil.example/x" } },
112
+ ],
113
+ [
114
+ "a look-alike Stripe host",
115
+ { ok: true, body: { url: "https://checkout.stripe.com.evil.example/x" } },
116
+ ],
117
+ [
118
+ "a URL with userinfo",
119
+ { ok: true, body: { url: "https://user@checkout.stripe.com/x" } },
120
+ ],
121
+ ["a body with no URL", { ok: true, body: {} }],
122
+ ])("%s is payment_failed with no redirect", async (_case, res) => {
123
+ mockPayArtifact.mockResolvedValue(res);
124
+ await expect(pay()).resolves.toEqual({
125
+ kind: "error",
126
+ reason: "payment_failed",
127
+ });
128
+ expect(mockPayArtifact).toHaveBeenCalledTimes(1);
129
+ expect(mockRedirect).not.toHaveBeenCalled();
130
+ });
131
+
132
+ it("a malformed piece id is payment_failed with no call", async () => {
133
+ await expect(pay("x")).resolves.toEqual({
134
+ kind: "error",
135
+ reason: "payment_failed",
136
+ });
137
+ expect(mockPayArtifact).not.toHaveBeenCalled();
138
+ expect(mockRedirect).not.toHaveBeenCalled();
139
+ });
140
+
141
+ it("a throw is payment_failed, logged by err.name only — never the URL or the piece id", async () => {
142
+ mockPayArtifact.mockRejectedValue(
143
+ new TypeError(`fetch failed ${CHECKOUT} ${PIECE}`),
144
+ );
145
+ await expect(pay()).resolves.toEqual({
146
+ kind: "error",
147
+ reason: "payment_failed",
148
+ });
149
+ expect(mockRedirect).not.toHaveBeenCalled();
150
+ expect(errorSpy).toHaveBeenCalledWith(
151
+ "[account] payment failed errName=TypeError",
152
+ );
153
+ const logged = JSON.stringify(errorSpy.mock.calls);
154
+ expect(logged).not.toContain(CHECKOUT);
155
+ expect(logged).not.toContain(PIECE);
156
+ });
157
+ });
158
+
159
+ describe("PayForm (D19, AC-7)", () => {
160
+ it("renders one primary Pay now button with a 44px target and no input of any kind", () => {
161
+ const { container } = render(<PayForm pieceId={PIECE} labels={LABELS} />);
162
+ expect(screen.getAllByRole("button")).toHaveLength(1);
163
+ const button = screen.getByTestId("artifact-pay");
164
+ expect(button).toHaveTextContent("Pay now");
165
+ expect(button).toHaveClass("min-h-(--control-height-big)");
166
+ expect(container.querySelectorAll("input, select, textarea")).toHaveLength(
167
+ 0,
168
+ );
169
+ expect(screen.queryByRole("textbox")).toBeNull();
170
+ expect(screen.queryByRole("spinbutton")).toBeNull();
171
+ expect(container.textContent).not.toMatch(/card|expiry|cvc/i);
172
+ });
173
+
174
+ it("Pay now sends the piece to payForProposal; a failure shows the error line", async () => {
175
+ const paySpy = jest.spyOn(actions, "payForProposal");
176
+ mockPayArtifact.mockResolvedValue(refusal(502, "payment_unavailable"));
177
+ render(<PayForm pieceId={PIECE} labels={LABELS} />);
178
+ await act(async () => {
179
+ fireEvent.click(screen.getByTestId("artifact-pay"));
180
+ });
181
+ expect(paySpy).toHaveBeenCalledTimes(1);
182
+ expect((paySpy.mock.calls[0]![1] as FormData).get("pieceId")).toBe(PIECE);
183
+ expect(mockPayArtifact).toHaveBeenCalledTimes(1);
184
+ expect(mockPayArtifact).toHaveBeenCalledWith(PIECE, INIT);
185
+ await waitFor(() =>
186
+ expect(screen.getByTestId("artifact-pay-error")).toHaveTextContent(
187
+ p.payError,
188
+ ),
189
+ );
190
+ expect(mockRedirect).not.toHaveBeenCalled();
191
+ paySpy.mockRestore();
192
+ });
193
+ });
@@ -10,6 +10,12 @@
10
10
  * the `FormData` each button sends: the verdict and the comment, a
11
11
  * refusal that keeps the typed comment, and a sent review that refreshes
12
12
  * the page once.
13
+ * - a proposal (#8516, spec
14
+ * docs/specs/8516-proposals-as-an-artifact-kind-spec.md D19, AC-3, AC-4):
15
+ * Approve proposal posts exactly what Approve posts; an approval whose
16
+ * answered artifact owes a payment opens it through `member.payArtifact`
17
+ * and redirects only to a Stripe Checkout URL — any other outcome is
18
+ * `payment_failed`, with no redirect, and the form then offers Pay now.
13
19
  */
14
20
 
15
21
  import {
@@ -21,10 +27,12 @@ import {
21
27
  } from "@testing-library/react";
22
28
 
23
29
  const mockReviewArtifact = jest.fn();
30
+ const mockPayArtifact = jest.fn();
24
31
  jest.mock("@working-theory/api-client", () => ({
25
32
  __esModule: true,
26
33
  member: {
27
34
  reviewArtifact: (...args: unknown[]) => mockReviewArtifact(...args),
35
+ payArtifact: (...args: unknown[]) => mockPayArtifact(...args),
28
36
  },
29
37
  }));
30
38
 
@@ -37,12 +45,19 @@ jest.mock("next/headers", () => ({
37
45
  }));
38
46
 
39
47
  const mockRefresh = jest.fn();
48
+ // Next's `redirect()` throws NEXT_REDIRECT; the mock does too, so a catch
49
+ // that swallowed it would show up as a resolved action.
50
+ const mockRedirect = jest.fn((url: string): never => {
51
+ throw new Error(`NEXT_REDIRECT ${url}`);
52
+ });
40
53
  jest.mock("next/navigation", () => ({
41
54
  __esModule: true,
42
55
  useRouter: () => ({ push: jest.fn(), refresh: mockRefresh }),
56
+ redirect: (url: string) => mockRedirect(url),
43
57
  }));
44
58
 
45
59
  import messages from "../../../../../../messages/en.json";
60
+ import { type PayFormLabels } from "../[pieceId]/pay-form";
46
61
  import { ReviewForm, type ReviewFormLabels } from "../[pieceId]/review-form";
47
62
  import * as actions from "../artifact-actions";
48
63
  import { submitArtifactReview } from "../artifact-actions";
@@ -52,6 +67,7 @@ const INIT = {
52
67
  headers: { cookie: "session=abc", origin: "https://site.example.com" },
53
68
  };
54
69
  const COMMENT = "Tighten the logo";
70
+ const CHECKOUT = "https://checkout.stripe.com/c/pay/cs_test_x";
55
71
 
56
72
  function form(fields: Record<string, string>): FormData {
57
73
  const data = new FormData();
@@ -88,6 +104,11 @@ beforeEach(() => {
88
104
  mockReviewArtifact
89
105
  .mockReset()
90
106
  .mockResolvedValue({ ok: true, body: { artifact: {} } });
107
+ mockPayArtifact.mockReset().mockResolvedValue({
108
+ ok: true,
109
+ body: { url: CHECKOUT },
110
+ });
111
+ mockRedirect.mockClear();
91
112
  mockRefresh.mockReset();
92
113
  errorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined);
93
114
  });
@@ -198,7 +219,116 @@ describe("submitArtifactReview (D6, AC-5)", () => {
198
219
  });
199
220
  });
200
221
 
222
+ /** The review's answer: the artifact as reviewed, with its payment state. */
223
+ function answered(payment: unknown) {
224
+ return {
225
+ ok: true,
226
+ body: {
227
+ artifact: {
228
+ id: PIECE,
229
+ title: "Site redesign",
230
+ approvedAt: "2026-10-05T09:00:00.000Z",
231
+ kind: "proposal",
232
+ price: { amount: 120000, currency: "usd" },
233
+ payment,
234
+ versions: [],
235
+ },
236
+ },
237
+ };
238
+ }
239
+
240
+ describe("submitArtifactReview on a proposal (#8516 D19, AC-3, AC-4)", () => {
241
+ it("Approve proposal posts { version, review: approved } once through member.reviewArtifact, exactly as Approve does", async () => {
242
+ mockReviewArtifact.mockResolvedValue(answered(null));
243
+ await expect(review({})).resolves.toEqual({ kind: "reviewed" });
244
+ expect(mockReviewArtifact).toHaveBeenCalledTimes(1);
245
+ expect(mockReviewArtifact).toHaveBeenCalledWith(
246
+ PIECE,
247
+ { version: 3, review: "approved" },
248
+ INIT,
249
+ );
250
+ expect(mockPayArtifact).not.toHaveBeenCalled();
251
+ expect(mockRedirect).not.toHaveBeenCalled();
252
+ });
253
+
254
+ it("an approval whose answered artifact is due calls member.payArtifact once and redirects to the Checkout URL", async () => {
255
+ mockReviewArtifact.mockResolvedValue(answered({ status: "due" }));
256
+ await expect(review({})).rejects.toThrow("NEXT_REDIRECT");
257
+ expect(mockReviewArtifact).toHaveBeenCalledTimes(1);
258
+ expect(mockPayArtifact).toHaveBeenCalledTimes(1);
259
+ expect(mockPayArtifact).toHaveBeenCalledWith(PIECE, INIT);
260
+ expect(mockRedirect).toHaveBeenCalledTimes(1);
261
+ expect(mockRedirect).toHaveBeenCalledWith(CHECKOUT);
262
+ });
263
+
264
+ it.each([
265
+ ["a payArtifact refusal", refusal(409, "proposal_not_approved")],
266
+ ["a 404 (no payment surface)", refusal(404, "not_found")],
267
+ [
268
+ "a URL on another host",
269
+ { ok: true, body: { url: "https://evil.example/x" } },
270
+ ],
271
+ [
272
+ "a look-alike Stripe host",
273
+ { ok: true, body: { url: "https://checkout.stripe.com.evil.example/x" } },
274
+ ],
275
+ [
276
+ "a plain-http Checkout URL",
277
+ { ok: true, body: { url: "http://checkout.stripe.com/x" } },
278
+ ],
279
+ ])(
280
+ "%s after a due approval is payment_failed with no redirect",
281
+ async (_case, res) => {
282
+ mockReviewArtifact.mockResolvedValue(answered({ status: "due" }));
283
+ mockPayArtifact.mockResolvedValue(res);
284
+ await expect(review({})).resolves.toEqual({
285
+ kind: "error",
286
+ reason: "payment_failed",
287
+ });
288
+ expect(mockReviewArtifact).toHaveBeenCalledTimes(1);
289
+ expect(mockPayArtifact).toHaveBeenCalledTimes(1);
290
+ expect(mockRedirect).not.toHaveBeenCalled();
291
+ },
292
+ );
293
+
294
+ it("a payArtifact throw is payment_failed, logged by err.name only — never the URL", async () => {
295
+ mockReviewArtifact.mockResolvedValue(answered({ status: "due" }));
296
+ mockPayArtifact.mockRejectedValue(
297
+ new TypeError(`fetch failed ${CHECKOUT}`),
298
+ );
299
+ await expect(review({})).resolves.toEqual({
300
+ kind: "error",
301
+ reason: "payment_failed",
302
+ });
303
+ expect(mockRedirect).not.toHaveBeenCalled();
304
+ expect(errorSpy).toHaveBeenCalledWith(
305
+ "[account] payment failed errName=TypeError",
306
+ );
307
+ expect(JSON.stringify(errorSpy.mock.calls)).not.toContain(CHECKOUT);
308
+ });
309
+
310
+ it.each([
311
+ ["paid", { status: "paid", paidAt: "2026-10-06T09:30:00.000Z" }],
312
+ ["no payment surface", null],
313
+ ])("an approval answered %s opens no payment", async (_case, payment) => {
314
+ mockReviewArtifact.mockResolvedValue(answered(payment));
315
+ await expect(review({})).resolves.toEqual({ kind: "reviewed" });
316
+ expect(mockPayArtifact).not.toHaveBeenCalled();
317
+ expect(mockRedirect).not.toHaveBeenCalled();
318
+ });
319
+
320
+ it("a changes-requested review never opens a payment", async () => {
321
+ mockReviewArtifact.mockResolvedValue(answered({ status: "due" }));
322
+ await expect(
323
+ review({ review: "changes-requested", comment: COMMENT }),
324
+ ).resolves.toEqual({ kind: "reviewed" });
325
+ expect(mockPayArtifact).not.toHaveBeenCalled();
326
+ expect(mockRedirect).not.toHaveBeenCalled();
327
+ });
328
+ });
329
+
201
330
  const r = messages.account.artifacts.review;
331
+ const p = messages.account.artifacts.proposal;
202
332
  const LABELS: ReviewFormLabels = {
203
333
  heading: r.heading.replace("{version}", "3"),
204
334
  comment: r.comment,
@@ -213,8 +343,14 @@ const LABELS: ReviewFormLabels = {
213
343
  not_latest: r.errors.notLatest,
214
344
  rate_limited: r.errors.rateLimited,
215
345
  failed: r.errors.failed,
346
+ payment_failed: p.payError,
216
347
  },
217
348
  };
349
+ const PAY_LABELS: PayFormLabels = {
350
+ pay: p.pay,
351
+ paying: p.paying,
352
+ error: p.payError,
353
+ };
218
354
 
219
355
  describe("ReviewForm (D7, AC-6)", () => {
220
356
  // A pass-through spy: the real action runs; the spy reads what it was sent.
@@ -286,3 +422,55 @@ describe("ReviewForm (D7, AC-6)", () => {
286
422
  );
287
423
  });
288
424
  });
425
+
426
+ describe("ReviewForm on a proposal (#8516 D19, AC-3)", () => {
427
+ const PROPOSAL_LABELS: ReviewFormLabels = {
428
+ ...LABELS,
429
+ heading: p.heading.replace("{version}", "1"),
430
+ approve: p.approve,
431
+ };
432
+
433
+ it("labels the decision by prop: Your decision on version <n>, Approve proposal, Request changes", () => {
434
+ render(
435
+ <ReviewForm
436
+ pieceId={PIECE}
437
+ version={1}
438
+ labels={PROPOSAL_LABELS}
439
+ pay={PAY_LABELS}
440
+ />,
441
+ );
442
+ expect(screen.getByRole("heading", { level: 3 })).toHaveTextContent(
443
+ "Your decision on version 1",
444
+ );
445
+ expect(screen.getByTestId("artifact-review-approve")).toHaveTextContent(
446
+ "Approve proposal",
447
+ );
448
+ expect(
449
+ screen.getByTestId("artifact-review-request-changes"),
450
+ ).toHaveTextContent("Request changes");
451
+ });
452
+
453
+ it("an approval whose payment could not open shows the error and offers Pay now in the form's place", async () => {
454
+ mockReviewArtifact.mockResolvedValue(answered({ status: "due" }));
455
+ mockPayArtifact.mockResolvedValue(refusal(502, "payment_unavailable"));
456
+ render(
457
+ <ReviewForm
458
+ pieceId={PIECE}
459
+ version={3}
460
+ labels={PROPOSAL_LABELS}
461
+ pay={PAY_LABELS}
462
+ />,
463
+ );
464
+ await act(async () => {
465
+ fireEvent.click(screen.getByTestId("artifact-review-approve"));
466
+ });
467
+ await waitFor(() =>
468
+ expect(screen.getByTestId("artifact-review-error")).toHaveTextContent(
469
+ p.payError,
470
+ ),
471
+ );
472
+ expect(screen.getByTestId("artifact-pay")).toHaveTextContent("Pay now");
473
+ expect(screen.queryByTestId("artifact-review-approve")).toBeNull();
474
+ expect(mockRedirect).not.toHaveBeenCalled();
475
+ });
476
+ });