create-ailk 0.19.1 → 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 (132) hide show
  1. package/README.md +64 -7
  2. package/dist/cli.js +35 -0
  3. package/dist/copy.d.ts +41 -0
  4. package/dist/copy.js +87 -21
  5. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  6. package/dist/lib/fetch-template-snapshot.js +205 -0
  7. package/dist/lib/merge-three-way.d.ts +31 -0
  8. package/dist/lib/merge-three-way.js +65 -0
  9. package/dist/module-architecture.js +55 -2
  10. package/dist/programmatic.d.ts +7 -1
  11. package/dist/programmatic.js +32 -2
  12. package/dist/spoke-checkout.d.ts +7 -0
  13. package/dist/spoke-checkout.js +8 -1
  14. package/dist/surfaces.js +67 -2
  15. package/dist/template-record.d.ts +59 -0
  16. package/dist/template-record.js +183 -0
  17. package/dist/update.d.ts +48 -0
  18. package/dist/update.js +504 -0
  19. package/package.json +3 -3
  20. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  21. package/templates/.claude/hooks/architecture-check.sh +8 -0
  22. package/templates/.claude/rules/loop-contract.md +129 -0
  23. package/templates/.claude/settings.json +3 -2
  24. package/templates/.env.example +13 -0
  25. package/templates/CLAUDE.md +1 -1
  26. package/templates/CONVENTIONS.md +3 -2
  27. package/templates/_gitignore +162 -0
  28. package/templates/apps/api/CLAUDE.md +14 -10
  29. package/templates/apps/api/package.json +2 -1
  30. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +22 -2
  31. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  32. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  33. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  34. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  35. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  36. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  37. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +5 -2
  38. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  39. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  40. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  41. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +21 -14
  42. package/templates/apps/api/src/middleware/auth.ts +8 -4
  43. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  44. package/templates/apps/api/src/middleware/rate-limit.ts +290 -82
  45. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  46. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  47. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  48. package/templates/apps/api/src/routes/webhooks/stripe.ts +184 -6
  49. package/templates/apps/api/src/server.ts +14 -2
  50. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  51. package/templates/apps/api/src/services/audit-log.ts +72 -10
  52. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  53. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  54. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  55. package/templates/apps/api/vercel.json +1 -1
  56. package/templates/apps/mcp/CLAUDE.md +19 -7
  57. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  58. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  59. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  60. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  61. package/templates/apps/web/_gitignore +6 -0
  62. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  63. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  64. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  65. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +28 -83
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +27 -13
  69. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  70. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +2 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +102 -18
  72. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +3 -2
  75. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  77. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  78. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  79. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  80. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  81. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  82. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  83. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  85. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  86. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +11 -2
  87. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +5 -24
  88. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +64 -18
  89. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  92. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  93. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  94. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +5 -3
  96. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  97. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  98. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  99. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  100. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  101. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  102. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  103. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  104. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  105. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  106. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  107. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  108. package/templates/apps/web/jest.config.cjs +3 -0
  109. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  110. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  111. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  112. package/templates/apps/web/lib/account-config.ts +31 -16
  113. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  114. package/templates/apps/web/lib/relay.ts +20 -0
  115. package/templates/apps/web/messages/en.json +67 -4
  116. package/templates/apps/web/next.config.mjs +8 -2
  117. package/templates/database/CHANGELOG.md +39 -0
  118. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  119. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  120. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  121. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  122. package/templates/database/_gitignore +8 -0
  123. package/templates/database/inbox/schema.prisma +38 -12
  124. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  125. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  126. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  127. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  128. package/templates/database/ops/schema.prisma +16 -0
  129. package/templates/database/package.json +1 -1
  130. package/templates/project.yaml +6 -3
  131. package/versions.json +35 -35
  132. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -7,6 +7,13 @@
7
7
  * session, an empty stamp, and the hub's own stamp are handled exactly as
8
8
  * today (roadmap D4). Spec D3/D7, AC-2 … AC-4.
9
9
  *
10
+ * Cases 9–17 (#8024, spec D4): the same filter over the billing families —
11
+ * `customer.*`, `customer.subscription.*` and `invoice.*` — read from the
12
+ * object's own metadata and, for an invoice, from the subscription snapshot
13
+ * at both API-version locations. A foreign event answers 200 and touches no
14
+ * prisma mock at all; an unstamped, empty or own stamp reaches its arm as
15
+ * today.
16
+ *
10
17
  * The stripe-flow-checkout.test.ts harness: the commerce claim mocked present,
11
18
  * the prisma manual mock, `verifyWebhookSignature` mocked at the package
12
19
  * boundary, the idempotency store reset per test. The seven existing
@@ -50,6 +57,10 @@ jest.mock("@working-theory/stripe-client", () => ({
50
57
  StripeConfigError: class StripeConfigError extends Error {},
51
58
  UnknownProductCodeError: class UnknownProductCodeError extends Error {},
52
59
  WEBHOOK_TOLERANCE_SECONDS: 300,
60
+ // Real, as in stripe-dunning.test.ts — the #8024 cases drive the
61
+ // `customer.subscription.updated` arm, which narrows the status with it.
62
+ toSubscriptionLifecycleStatus: jest.requireActual("@working-theory/stripe-client")
63
+ .toSubscriptionLifecycleStatus,
53
64
  }));
54
65
 
55
66
  import * as configModule from "../../../config/index.js";
@@ -364,3 +375,434 @@ describe("case 8 — a foreign event delivered twice", () => {
364
375
  expectNoHubSideEffect();
365
376
  });
366
377
  });
378
+
379
+ // ── #8024 — the billing families ────────────────────────────────────────────
380
+
381
+ const SUBSCRIPTION_ID = "sub_test_12345678901234567890";
382
+ const INVOICE_ID = "in_test_12345678901234567890";
383
+ const FOREIGN = { site: "samhenry" };
384
+
385
+ /** Every fixture carries `data.object.object`, as Stripe sends it. */
386
+ function billingEvent(type: string, object: Record<string, unknown>, extra: Record<string, unknown> = {}) {
387
+ return { id: "evt_test_12345678abcdef", type, created: 1_760_000_000, data: { object }, ...extra };
388
+ }
389
+
390
+ function customerEvent(type: string, metadata: unknown) {
391
+ return billingEvent(type, { object: "customer", id: CUSTOMER_ID, email: EMAIL, metadata });
392
+ }
393
+
394
+ function subscriptionEvent(type: string, metadata: unknown) {
395
+ return billingEvent(type, {
396
+ object: "subscription",
397
+ id: SUBSCRIPTION_ID,
398
+ customer: CUSTOMER_ID,
399
+ status: "past_due",
400
+ metadata,
401
+ });
402
+ }
403
+
404
+ type InvoiceStamps = { metadata?: unknown; snapshot?: unknown };
405
+
406
+ /** The pinned API version's shape: the subscription snapshot under `parent`, no top-level `subscription`. */
407
+ function dahliaInvoice(type: string, { metadata = {}, snapshot = {} }: InvoiceStamps = {}) {
408
+ return billingEvent(type, {
409
+ object: "invoice",
410
+ id: INVOICE_ID,
411
+ customer: CUSTOMER_ID,
412
+ customer_email: EMAIL,
413
+ metadata,
414
+ parent: {
415
+ type: "subscription_details",
416
+ subscription_details: { subscription: SUBSCRIPTION_ID, metadata: snapshot },
417
+ },
418
+ });
419
+ }
420
+
421
+ /** A pre-`basil` endpoint's shape: top-level `subscription` and `subscription_details`. */
422
+ function preBasilInvoice(type: string, { metadata = {}, snapshot = {} }: InvoiceStamps = {}) {
423
+ return billingEvent(type, {
424
+ object: "invoice",
425
+ id: INVOICE_ID,
426
+ customer: CUSTOMER_ID,
427
+ customer_email: EMAIL,
428
+ metadata,
429
+ subscription: SUBSCRIPTION_ID,
430
+ subscription_details: { metadata: snapshot },
431
+ });
432
+ }
433
+
434
+ /** Arms every billing arm so that, unfiltered, it reaches a read or a write. */
435
+ function armEveryWrite() {
436
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue({
437
+ id: USER_ID,
438
+ billingProvider: null,
439
+ billingCustomerId: null,
440
+ });
441
+ (prisma.user.update as jest.Mock).mockResolvedValue({});
442
+ (prisma.userData.findUnique as jest.Mock).mockResolvedValue(null);
443
+ (prisma.userData.create as jest.Mock).mockResolvedValue({});
444
+ (prisma.userData.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
445
+ (prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({ value: { graceDays: 14 } });
446
+ }
447
+
448
+ /**
449
+ * No call on ANY method of the prisma manual mock — every delegate's every
450
+ * `jest.fn`, and the client-level ones (`$transaction`, `$queryRaw`, …) —
451
+ * and no telemetry. A walk, not a fixed list, so a write outside the arms
452
+ * this file knows about cannot pass (§ 9 SEC-8024-5).
453
+ */
454
+ function expectNoPrismaCall() {
455
+ const called: string[] = [];
456
+ for (const [name, member] of Object.entries(prisma)) {
457
+ if (jest.isMockFunction(member)) {
458
+ if (member.mock.calls.length > 0) called.push(name);
459
+ continue;
460
+ }
461
+ if (typeof member !== "object" || member === null) continue;
462
+ for (const [method, fn] of Object.entries(member as Record<string, unknown>)) {
463
+ if (jest.isMockFunction(fn) && fn.mock.calls.length > 0) called.push(`${name}.${method}`);
464
+ }
465
+ }
466
+ expect(called).toEqual([]);
467
+ expect(mockTrack).not.toHaveBeenCalled();
468
+ }
469
+
470
+ function logMessages() {
471
+ return logCalls.map(([, msg]) => msg);
472
+ }
473
+
474
+ function outcomeLogs(outcome: string) {
475
+ return logCalls.filter(
476
+ ([obj]) => typeof obj === "object" && obj !== null && (obj as Record<string, unknown>)["outcome"] === outcome,
477
+ );
478
+ }
479
+
480
+ function expectForeignSkip(eventType: string, stamp = "samhenry", ownSite = "default") {
481
+ const lines = foreignSiteLogs();
482
+ expect(lines).toHaveLength(1);
483
+ expect(lines[0]![0]).toEqual({
484
+ eventType,
485
+ eventIdPrefix: "evt_test",
486
+ outcome: "foreign_site",
487
+ stamp,
488
+ ownSite,
489
+ });
490
+ }
491
+
492
+ function expectDunningWritten() {
493
+ expect(prisma.user.findUnique).toHaveBeenCalledWith({
494
+ where: {
495
+ billingProvider_billingCustomerId: { billingProvider: "stripe", billingCustomerId: CUSTOMER_ID },
496
+ },
497
+ select: { id: true },
498
+ });
499
+ expect(prisma.userData.create).toHaveBeenCalledTimes(1);
500
+ }
501
+
502
+ // ── Case 9 — a foreign event on every handled arm writes nothing (AC-1) ────
503
+
504
+ describe("case 9 — a billing event stamped for another site, on every handled arm", () => {
505
+ it.each([
506
+ ["customer.created (ailk_user_id)", customerEvent("customer.created", { ...FOREIGN, ailk_user_id: USER_ID })],
507
+ ["customer.created (organizationId)", customerEvent("customer.created", { ...FOREIGN, organizationId: ORG_ID })],
508
+ ["customer.subscription.updated", subscriptionEvent("customer.subscription.updated", FOREIGN)],
509
+ ["invoice.payment_failed (dahlia)", dahliaInvoice("invoice.payment_failed", { snapshot: FOREIGN })],
510
+ ["invoice.payment_failed (pre-basil)", preBasilInvoice("invoice.payment_failed", { snapshot: FOREIGN })],
511
+ ["invoice.paid (dahlia)", dahliaInvoice("invoice.paid", { snapshot: FOREIGN })],
512
+ ["invoice.paid (pre-basil)", preBasilInvoice("invoice.paid", { snapshot: FOREIGN })],
513
+ ])("%s answers 200, logs one foreign_site line and touches no prisma mock", async (_label, event) => {
514
+ armEveryWrite();
515
+ mockVerifyWebhookSignature.mockReturnValue(event);
516
+ const res = await post();
517
+
518
+ expect(res.statusCode).toBe(200);
519
+ expect(JSON.parse(res.body)).toEqual({ received: true });
520
+ expectNoPrismaCall();
521
+ expectForeignSkip(event.type);
522
+ expect(outcomeLogs("not_a_subscription_invoice")).toHaveLength(0);
523
+
524
+ const logged = JSON.stringify(logCalls);
525
+ expect(logged).not.toContain(CUSTOMER_ID);
526
+ expect(logged).not.toContain(USER_ID);
527
+ expect(logged).not.toContain(EMAIL);
528
+ });
529
+ });
530
+
531
+ // ── Case 10 — the gate is family-wide, not per arm (AC-1) ───────────────────
532
+
533
+ describe("case 10 — a foreign event of an unhandled type in each family", () => {
534
+ it.each([
535
+ ["customer.updated", customerEvent("customer.updated", FOREIGN)],
536
+ ["customer.subscription.deleted", subscriptionEvent("customer.subscription.deleted", FOREIGN)],
537
+ ["invoice.finalized", dahliaInvoice("invoice.finalized", { snapshot: FOREIGN })],
538
+ ])("%s logs foreign_site and never reaches the default arm", async (_label, event) => {
539
+ mockVerifyWebhookSignature.mockReturnValue(event);
540
+ const res = await post();
541
+
542
+ expect(res.statusCode).toBe(200);
543
+ expectNoPrismaCall();
544
+ expectForeignSkip(event.type);
545
+ expect(logMessages()).not.toContain("unhandled webhook event");
546
+ });
547
+ });
548
+
549
+ // ── Case 11 — every invoice source counts, and any foreign one wins (AC-1) ──
550
+
551
+ describe("case 11 — an invoice stamped at each of its three sources", () => {
552
+ it.each([
553
+ ["its own metadata", preBasilInvoice("invoice.payment_failed", { metadata: FOREIGN })],
554
+ ["the dahlia snapshot only", dahliaInvoice("invoice.payment_failed", { snapshot: FOREIGN })],
555
+ ["the pre-basil snapshot only", preBasilInvoice("invoice.payment_failed", { snapshot: FOREIGN })],
556
+ ])("a stamp in %s is foreign", async (_label, event) => {
557
+ armEveryWrite();
558
+ mockVerifyWebhookSignature.mockReturnValue(event);
559
+ await post();
560
+
561
+ expectNoPrismaCall();
562
+ expectForeignSkip("invoice.payment_failed");
563
+ });
564
+
565
+ it.each([
566
+ ["dahlia", dahliaInvoice("invoice.payment_failed", { metadata: { site: "default" }, snapshot: FOREIGN })],
567
+ ["pre-basil", preBasilInvoice("invoice.payment_failed", { metadata: { site: "default" }, snapshot: FOREIGN })],
568
+ ])("an own invoice stamp over a foreign snapshot is foreign, and logs the foreign stamp (%s)", async (_label, event) => {
569
+ armEveryWrite();
570
+ mockVerifyWebhookSignature.mockReturnValue(event);
571
+ await post();
572
+
573
+ expectNoPrismaCall();
574
+ expectForeignSkip("invoice.payment_failed", "samhenry");
575
+ });
576
+ });
577
+
578
+ // ── Case 12 — a foreign billing event is marked seen (F3) ───────────────────
579
+
580
+ describe("case 12 — a foreign subscription event delivered twice", () => {
581
+ it("takes the duplicate short-circuit on the redelivery", async () => {
582
+ armEveryWrite();
583
+ mockVerifyWebhookSignature.mockReturnValue(
584
+ subscriptionEvent("customer.subscription.updated", FOREIGN),
585
+ );
586
+ await post();
587
+ expect(foreignSiteLogs()).toHaveLength(1);
588
+
589
+ logCalls.length = 0;
590
+ const second = await post();
591
+
592
+ expect(second.statusCode).toBe(200);
593
+ expect(foreignSiteLogs()).toHaveLength(0);
594
+ expect(logMessages()).toContain("duplicate stripe webhook event — idempotent short-circuit");
595
+ expectNoPrismaCall();
596
+ });
597
+ });
598
+
599
+ // ── Case 13 — unstamped, empty and own stamps keep today's behavior (AC-2) ──
600
+
601
+ const HUB_STAMPS: Array<[string, Record<string, string>]> = [
602
+ ["no stamp", {}],
603
+ ["an empty stamp", { site: "" }],
604
+ ["the own stamp", { site: "default" }],
605
+ ];
606
+
607
+ describe.each(HUB_STAMPS)("case 13 — a billing event with %s", (_label, stamp) => {
608
+ beforeEach(() => armEveryWrite());
609
+
610
+ afterEach(() => {
611
+ expect(foreignSiteLogs()).toHaveLength(0);
612
+ });
613
+
614
+ it("customer.created with ailk_user_id binds the User's billing customer", async () => {
615
+ mockVerifyWebhookSignature.mockReturnValue(
616
+ customerEvent("customer.created", { ...stamp, ailk_user_id: USER_ID }),
617
+ );
618
+ const res = await post();
619
+
620
+ expect(res.statusCode).toBe(200);
621
+ expect(prisma.user.update).toHaveBeenCalledWith({
622
+ where: { id: USER_ID, billingCustomerId: null },
623
+ data: { billingProvider: "stripe", billingCustomerId: CUSTOMER_ID },
624
+ });
625
+ });
626
+
627
+ it("customer.created with organizationId binds the Organization's billing customer", async () => {
628
+ mockVerifyWebhookSignature.mockReturnValue(
629
+ customerEvent("customer.created", { ...stamp, organizationId: ORG_ID }),
630
+ );
631
+ const res = await post();
632
+
633
+ expect(res.statusCode).toBe(200);
634
+ expect(prisma.organization.update).toHaveBeenCalledWith({
635
+ where: { id: ORG_ID },
636
+ data: { billingProvider: "stripe", billingCustomerId: CUSTOMER_ID },
637
+ });
638
+ });
639
+
640
+ it("customer.subscription.updated writes the subscriber's dunning record", async () => {
641
+ mockVerifyWebhookSignature.mockReturnValue(subscriptionEvent("customer.subscription.updated", stamp));
642
+ const res = await post();
643
+
644
+ expect(res.statusCode).toBe(200);
645
+ expectDunningWritten();
646
+ });
647
+
648
+ it("a pre-basil invoice.payment_failed writes the subscriber's dunning record", async () => {
649
+ mockVerifyWebhookSignature.mockReturnValue(preBasilInvoice("invoice.payment_failed", { snapshot: stamp }));
650
+ const res = await post();
651
+
652
+ expect(res.statusCode).toBe(200);
653
+ expectDunningWritten();
654
+ });
655
+
656
+ it("a dahlia invoice.payment_failed logs not_a_subscription_invoice, as today", async () => {
657
+ mockVerifyWebhookSignature.mockReturnValue(dahliaInvoice("invoice.payment_failed", { snapshot: stamp }));
658
+ const res = await post();
659
+
660
+ expect(res.statusCode).toBe(200);
661
+ expect(outcomeLogs("not_a_subscription_invoice")).toHaveLength(1);
662
+ expect(prisma.userData.create).not.toHaveBeenCalled();
663
+ });
664
+
665
+ it.each([
666
+ ["dahlia", dahliaInvoice],
667
+ ["pre-basil", preBasilInvoice],
668
+ ])("a %s invoice.paid runs the request-paid stamp", async (_shape, invoice) => {
669
+ mockVerifyWebhookSignature.mockReturnValue(invoice("invoice.paid", { snapshot: stamp }));
670
+ const res = await post();
671
+
672
+ expect(res.statusCode).toBe(200);
673
+ expect(prisma.callback.updateMany).toHaveBeenCalledWith({
674
+ where: { stripeInvoiceId: INVOICE_ID, paidAt: null },
675
+ data: { paidAt: expect.any(Date) },
676
+ });
677
+ });
678
+ });
679
+
680
+ // ── Case 14 — the alias is read per call (AC-2) ─────────────────────────────
681
+
682
+ describe("case 14 — under AILK_SITE_ID=wksp_hub", () => {
683
+ beforeEach(() => {
684
+ process.env.AILK_SITE_ID = "wksp_hub";
685
+ armEveryWrite();
686
+ });
687
+
688
+ it("a subscription stamped wksp_hub is own, and writes its dunning record", async () => {
689
+ mockVerifyWebhookSignature.mockReturnValue(
690
+ subscriptionEvent("customer.subscription.updated", { site: "wksp_hub" }),
691
+ );
692
+ await post();
693
+
694
+ expectDunningWritten();
695
+ expect(foreignSiteLogs()).toHaveLength(0);
696
+ });
697
+
698
+ it("a subscription stamped default is foreign", async () => {
699
+ mockVerifyWebhookSignature.mockReturnValue(
700
+ subscriptionEvent("customer.subscription.updated", { site: "default" }),
701
+ );
702
+ await post();
703
+
704
+ expectNoPrismaCall();
705
+ expectForeignSkip("customer.subscription.updated", "default", "wksp_hub");
706
+ });
707
+ });
708
+
709
+ // ── Case 15 — malformed shapes never throw (NF1) ────────────────────────────
710
+
711
+ describe("case 15 — malformed payload shapes are unstamped and answer 200", () => {
712
+ const noObject: Array<[string, Record<string, unknown>]> = [
713
+ ["event.data null", { data: null }],
714
+ ["event.data absent", { data: undefined }],
715
+ ["data.object null", { data: { object: null } }],
716
+ ["data.object absent", { data: {} }],
717
+ ];
718
+
719
+ it.each(
720
+ ["customer.updated", "customer.subscription.deleted", "invoice.finalized"].flatMap((type) =>
721
+ noObject.map(([label, shape]) => [type, label, shape] as const),
722
+ ),
723
+ )("%s with %s reaches the default arm", async (type, _label, shape) => {
724
+ mockVerifyWebhookSignature.mockReturnValue({ id: "evt_test_12345678abcdef", type, ...shape });
725
+ const res = await post();
726
+
727
+ expect(res.statusCode).toBe(200);
728
+ expect(JSON.parse(res.body)).toEqual({ received: true });
729
+ expect(foreignSiteLogs()).toHaveLength(0);
730
+ expect(logMessages()).toContain("unhandled webhook event");
731
+ });
732
+
733
+ function withObject(event: ReturnType<typeof billingEvent>, patch: Record<string, unknown>) {
734
+ return { ...event, data: { object: { ...event.data.object, ...patch } } };
735
+ }
736
+
737
+ const subscription = subscriptionEvent("customer.subscription.updated", FOREIGN);
738
+ const invoice = preBasilInvoice("invoice.payment_failed");
739
+
740
+ it.each([
741
+ ["data.object.object absent on a stamped subscription", withObject(subscription, { object: undefined })],
742
+ ["metadata null", withObject(subscription, { metadata: null })],
743
+ ["metadata a string", withObject(subscription, { metadata: "samhenry" })],
744
+ ["metadata an array", withObject(subscription, { metadata: ["samhenry"] })],
745
+ ["a non-string stamp", withObject(subscription, { metadata: { site: 42 } })],
746
+ ["an invoice with parent null", withObject(invoice, { parent: null })],
747
+ ["an invoice with parent a string", withObject(invoice, { parent: "subscription_details" })],
748
+ ["an invoice with parent.subscription_details null", withObject(invoice, { parent: { subscription_details: null } })],
749
+ ["an invoice with parent.subscription_details a string", withObject(invoice, { parent: { subscription_details: "sub" } })],
750
+ ["an invoice with top-level subscription_details a string", withObject(invoice, { subscription_details: "sub" })],
751
+ ])("%s reaches its arm as today", async (_label, event) => {
752
+ armEveryWrite();
753
+ mockVerifyWebhookSignature.mockReturnValue(event);
754
+ const res = await post();
755
+
756
+ expect(res.statusCode).toBe(200);
757
+ expect(foreignSiteLogs()).toHaveLength(0);
758
+ expectDunningWritten();
759
+ });
760
+ });
761
+
762
+ // ── Case 16 — the family boundary (F4) ──────────────────────────────────────
763
+
764
+ describe("case 16 — stamped events outside the three families", () => {
765
+ it.each([
766
+ ["payment_intent.succeeded", "payment_intent"],
767
+ ["invoiceitem.created", "invoiceitem"],
768
+ ["invoice_payment.paid", "invoice_payment"],
769
+ ["customer_cash_balance_transaction.created", "customer_cash_balance_transaction"],
770
+ ["customer.source.created", "card"],
771
+ ])("%s is not filtered", async (type, objectType) => {
772
+ mockVerifyWebhookSignature.mockReturnValue(
773
+ billingEvent(type, { object: objectType, id: "obj_test_1", customer: CUSTOMER_ID, metadata: FOREIGN }),
774
+ );
775
+ const res = await post();
776
+
777
+ expect(res.statusCode).toBe(200);
778
+ expect(foreignSiteLogs()).toHaveLength(0);
779
+ expect(logMessages()).toContain("unhandled webhook event");
780
+ });
781
+ });
782
+
783
+ // ── Case 17 — the livemode drop still runs first (#8450 D10) ────────────────
784
+
785
+ describe("case 17 — a foreign live-mode event under the test key", () => {
786
+ it("is dropped as livemode_mismatch, never marked seen, and never reaches the site filter", async () => {
787
+ mockVerifyWebhookSignature.mockReturnValue(
788
+ billingEvent(
789
+ "customer.subscription.updated",
790
+ { object: "subscription", id: SUBSCRIPTION_ID, customer: CUSTOMER_ID, status: "past_due", metadata: FOREIGN },
791
+ { livemode: true },
792
+ ),
793
+ );
794
+ const first = await post();
795
+
796
+ expect(first.statusCode).toBe(200);
797
+ expect(outcomeLogs("livemode_mismatch")).toHaveLength(1);
798
+ expect(foreignSiteLogs()).toHaveLength(0);
799
+
800
+ logCalls.length = 0;
801
+ await post();
802
+
803
+ expect(outcomeLogs("livemode_mismatch").length).toBeGreaterThan(0);
804
+ expect(logMessages()).not.toContain("duplicate stripe webhook event — idempotent short-circuit");
805
+ expect(foreignSiteLogs()).toHaveLength(0);
806
+ expectNoPrismaCall();
807
+ });
808
+ });