create-ailk 0.14.0 → 0.16.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.
- package/README.md +124 -0
- package/component-catalog.json +3 -3
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +80 -3
- package/dist/add-route.js +26 -2
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/apply-design.d.ts +12 -1
- package/dist/apply-design.js +36 -4
- package/dist/cli.js +103 -3
- package/dist/derive-architecture.d.ts +37 -2
- package/dist/derive-architecture.js +92 -7
- package/dist/design-package.js +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- package/dist/lib/declared-env.d.ts +142 -0
- package/dist/lib/declared-env.js +985 -0
- package/dist/lib/env-catalog.d.ts +17 -0
- package/dist/lib/env-catalog.js +234 -0
- package/dist/lib/extract-module-bundle.d.ts +20 -0
- package/dist/lib/extract-module-bundle.js +53 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +82 -0
- package/dist/parse-args.d.ts +27 -0
- package/dist/parse-args.js +89 -0
- package/dist/programmatic.d.ts +28 -0
- package/dist/programmatic.js +181 -11
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-chrome.d.ts +4 -0
- package/dist/site-chrome.js +34 -3
- package/dist/site-manifest.d.ts +55 -5
- package/dist/site-manifest.js +121 -12
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +42 -0
- package/dist/spoke-checkout.js +359 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +29 -5
- package/dist/topology.d.ts +246 -0
- package/dist/topology.js +942 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +21 -4
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
- package/templates/apps/api/src/bin/spokes.ts +62 -1
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +41 -3
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +129 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
- package/templates/apps/api/src/server.ts +105 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +278 -30
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/.env.example +58 -5
- package/templates/apps/web/CLAUDE.md +1 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
- package/templates/apps/web/app/[locale]/layout.tsx +83 -18
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
- package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
- package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
- package/templates/apps/web/app/openapi.json/route.ts +72 -7
- package/templates/apps/web/auth.ts +29 -2
- package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
- package/templates/apps/web/components/signed-in-nav.tsx +17 -3
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
- package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
- package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/account-config.ts +130 -1
- package/templates/apps/web/lib/api-origin.generated.ts +11 -0
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
- package/templates/apps/web/lib/hub-session/config.ts +104 -0
- package/templates/apps/web/lib/hub-session/index.ts +23 -0
- package/templates/apps/web/lib/hub-session/seal.ts +82 -0
- package/templates/apps/web/lib/hub-session/session.ts +180 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
- package/templates/apps/web/lib/listings-config.ts +31 -0
- package/templates/apps/web/lib/listings.tsx +7 -3
- package/templates/apps/web/lib/meeting-slugs.ts +11 -1
- package/templates/apps/web/lib/next-path.ts +35 -0
- package/templates/apps/web/lib/relay.ts +209 -3
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +3 -0
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +51 -0
- package/templates/database/__tests__/agents.db.test.ts +85 -0
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/__tests__/member-area.db.test.ts +245 -0
- package/templates/database/__tests__/person.db.test.ts +584 -0
- package/templates/database/auth/schema.prisma +24 -0
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +111 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
- package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
- package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/person-backfill.sql +132 -0
- package/templates/database/tenancy.ts +53 -0
- package/templates/tools/check-declared-env.ts +37 -0
- package/templates/tools/declared-env.ts +1163 -0
- package/versions.json +35 -34
|
@@ -328,3 +328,108 @@ describe("AC-5 — PII boundary", () => {
|
|
|
328
328
|
}
|
|
329
329
|
});
|
|
330
330
|
});
|
|
331
|
+
|
|
332
|
+
// ── #852 D6 — the hub checkout's Person (AC-3) ──────────────────────────────
|
|
333
|
+
//
|
|
334
|
+
// After the stamp, `recordCheckoutPerson` reads the checkout row THIS session
|
|
335
|
+
// completed, then its lead, then finds or creates the Person — with
|
|
336
|
+
// `session.customer` as the customer id only on a live-mode event. Observed
|
|
337
|
+
// through the shared mock's delegates: the `person.findFirst` keyed on a
|
|
338
|
+
// customer id is the customer-id step, so its absence means no id was passed.
|
|
339
|
+
describe("the hub checkout's Person (#852 D6, AC-3)", () => {
|
|
340
|
+
const WORKSPACE = "wksp_00000000-0000-7000-8000-000000000852";
|
|
341
|
+
const LEAD = "lead_00000000-0000-7000-8000-000000000852";
|
|
342
|
+
const personFindFirst = () => jest.mocked(prisma.person.findFirst);
|
|
343
|
+
const personCalls = () =>
|
|
344
|
+
[prisma.person.findFirst, prisma.person.createMany, prisma.person.updateMany].reduce(
|
|
345
|
+
(n, fn) => n + jest.mocked(fn).mock.calls.length,
|
|
346
|
+
0,
|
|
347
|
+
);
|
|
348
|
+
const personLines = () =>
|
|
349
|
+
logCalls.filter(
|
|
350
|
+
(c) => typeof c[0] === "object" && c[0] !== null && String((c[0] as { outcome?: string }).outcome).startsWith("person_"),
|
|
351
|
+
);
|
|
352
|
+
const liveEvent = (livemode: boolean, object: Record<string, unknown> = {}) => ({
|
|
353
|
+
...sessionEvent("checkout.session.completed", { customer: "cus_9", ...object }),
|
|
354
|
+
livemode,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
beforeEach(() => {
|
|
358
|
+
(prisma.flowCheckout.findFirst as jest.Mock).mockResolvedValue({ leadId: LEAD, workspaceId: WORKSPACE });
|
|
359
|
+
(prisma.lead.findFirst as jest.Mock).mockResolvedValue({ email: EMAIL });
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("a paid live-mode hub session finds the Person of its checkout's lead with session.customer", async () => {
|
|
363
|
+
personFindFirst().mockResolvedValueOnce(null); // no Person carries cus_9 yet
|
|
364
|
+
mockVerifyWebhookSignature.mockReturnValue(liveEvent(true));
|
|
365
|
+
expect((await post()).statusCode).toBe(200);
|
|
366
|
+
|
|
367
|
+
expect(prisma.flowCheckout.findFirst).toHaveBeenCalledWith({
|
|
368
|
+
where: { id: FLOW_CHECKOUT_ID, stripeSessionId: SESSION_ID, status: "completed" },
|
|
369
|
+
select: { leadId: true, workspaceId: true },
|
|
370
|
+
});
|
|
371
|
+
expect(prisma.lead.findFirst).toHaveBeenCalledWith({
|
|
372
|
+
where: { id: LEAD, workspaceId: WORKSPACE },
|
|
373
|
+
select: { email: true },
|
|
374
|
+
});
|
|
375
|
+
expect(personFindFirst()).toHaveBeenCalledWith(
|
|
376
|
+
expect.objectContaining({ where: { workspaceId: WORKSPACE, billingCustomerId: "cus_9" } }),
|
|
377
|
+
);
|
|
378
|
+
expect(personFindFirst()).toHaveBeenCalledWith(
|
|
379
|
+
expect.objectContaining({ where: { workspaceId: WORKSPACE, email: EMAIL } }),
|
|
380
|
+
);
|
|
381
|
+
// The stamp settles before the Person step reads the row it completed.
|
|
382
|
+
expect((prisma.flowCheckout.updateMany as jest.Mock).mock.invocationCallOrder[0]!).toBeLessThan(
|
|
383
|
+
(prisma.flowCheckout.findFirst as jest.Mock).mock.invocationCallOrder[0]!,
|
|
384
|
+
);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("the same session in test mode passes no customer id", async () => {
|
|
388
|
+
mockVerifyWebhookSignature.mockReturnValue(liveEvent(false));
|
|
389
|
+
expect((await post()).statusCode).toBe(200);
|
|
390
|
+
|
|
391
|
+
expect(personFindFirst()).toHaveBeenCalledWith(
|
|
392
|
+
expect.objectContaining({ where: { workspaceId: WORKSPACE, email: EMAIL } }),
|
|
393
|
+
);
|
|
394
|
+
for (const [args] of personFindFirst().mock.calls) {
|
|
395
|
+
expect((args as { where: Record<string, unknown> }).where).not.toHaveProperty("billingCustomerId");
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("a session naming a checkout another session completed makes no person call and logs person_skipped", async () => {
|
|
400
|
+
// The read is keyed on THIS session's id, so another session's row is no row.
|
|
401
|
+
(prisma.flowCheckout.findFirst as jest.Mock).mockResolvedValue(null);
|
|
402
|
+
mockVerifyWebhookSignature.mockReturnValue(liveEvent(true));
|
|
403
|
+
expect((await post()).statusCode).toBe(200);
|
|
404
|
+
|
|
405
|
+
expect(personCalls()).toBe(0);
|
|
406
|
+
expect(personLines().map((c) => (c[0] as { outcome: string }).outcome)).toEqual(["person_skipped"]);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it.each([
|
|
410
|
+
["a foreign-site session", { metadata: { flowCheckoutId: FLOW_CHECKOUT_ID, site: "wksp_other-spoke" } }],
|
|
411
|
+
["an unpaid session", { payment_status: "unpaid" }],
|
|
412
|
+
["a session with no flowCheckoutId", { metadata: { flowSlug: "ailk-reco" } }],
|
|
413
|
+
])("%s makes no person call", async (_label, object) => {
|
|
414
|
+
mockVerifyWebhookSignature.mockReturnValue(liveEvent(true, object));
|
|
415
|
+
expect((await post()).statusCode).toBe(200);
|
|
416
|
+
expect(prisma.flowCheckout.findFirst).not.toHaveBeenCalled();
|
|
417
|
+
expect(personCalls()).toBe(0);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("a Person step that throws still answers 200 and logs person_failed with no email or customer id", async () => {
|
|
421
|
+
personFindFirst().mockRejectedValueOnce(new Error(`relation people down for ${EMAIL} cus_9`));
|
|
422
|
+
mockVerifyWebhookSignature.mockReturnValue(liveEvent(true));
|
|
423
|
+
expect((await post()).statusCode).toBe(200);
|
|
424
|
+
|
|
425
|
+
const lines = personLines();
|
|
426
|
+
expect(lines.map((c) => c[0])).toEqual([
|
|
427
|
+
{ eventIdPrefix: "evt_test", flowCheckoutIdPrefix: FLOW_CHECKOUT_ID.substring(0, 8), outcome: "person_failed" },
|
|
428
|
+
]);
|
|
429
|
+
for (const line of logCalls) {
|
|
430
|
+
const text = JSON.stringify(line);
|
|
431
|
+
expect(text).not.toContain(EMAIL);
|
|
432
|
+
expect(text).not.toContain("cus_9");
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
});
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/webhooks/stripe — the hub's site filter (#7868).
|
|
3
|
+
*
|
|
4
|
+
* A Checkout session stamped `metadata.site` for ANOTHER site (a spoke on the
|
|
5
|
+
* shared Stripe account) produces no hub side effect on
|
|
6
|
+
* `checkout.session.completed` or `.expired`, and answers 200. An unstamped
|
|
7
|
+
* session, an empty stamp, and the hub's own stamp are handled exactly as
|
|
8
|
+
* today (roadmap D4). Spec D3/D7, AC-2 … AC-4.
|
|
9
|
+
*
|
|
10
|
+
* The stripe-flow-checkout.test.ts harness: the commerce claim mocked present,
|
|
11
|
+
* the prisma manual mock, `verifyWebhookSignature` mocked at the package
|
|
12
|
+
* boundary, the idempotency store reset per test. The seven existing
|
|
13
|
+
* `stripe*.test.ts` suites are the regression floor and are not edited.
|
|
14
|
+
*/
|
|
15
|
+
import type { FastifyInstance } from "fastify";
|
|
16
|
+
import Fastify from "fastify";
|
|
17
|
+
|
|
18
|
+
const mockTrack = jest.fn().mockResolvedValue(undefined);
|
|
19
|
+
jest.mock("@working-theory/license", () => ({
|
|
20
|
+
checkLicense: jest.fn().mockResolvedValue({
|
|
21
|
+
tier: "pro",
|
|
22
|
+
plan: "pro-monthly",
|
|
23
|
+
features: ["commerce"],
|
|
24
|
+
expiresAt: null,
|
|
25
|
+
revoked: false,
|
|
26
|
+
}),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
jest.mock("@working-theory/analytics/server", () => ({
|
|
30
|
+
track: (...args: unknown[]) => mockTrack(...args),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
|
|
34
|
+
jest.mock("../../../lib/prisma.js");
|
|
35
|
+
|
|
36
|
+
const mockVerifyWebhookSignature = jest.fn();
|
|
37
|
+
class MockStripeWebhookSignatureError extends Error {
|
|
38
|
+
constructor(message: string) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "StripeWebhookSignatureError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
jest.mock("@working-theory/stripe-client", () => ({
|
|
44
|
+
verifyWebhookSignature: (...args: unknown[]) => mockVerifyWebhookSignature(...args),
|
|
45
|
+
StripeWebhookSignatureError: MockStripeWebhookSignatureError,
|
|
46
|
+
assertSandboxKey: jest.fn(),
|
|
47
|
+
createCheckoutSession: jest.fn(),
|
|
48
|
+
getStripeClient: jest.fn(),
|
|
49
|
+
STRIPE_API_VERSION: "2024-06-20",
|
|
50
|
+
StripeConfigError: class StripeConfigError extends Error {},
|
|
51
|
+
UnknownProductCodeError: class UnknownProductCodeError extends Error {},
|
|
52
|
+
WEBHOOK_TOLERANCE_SECONDS: 300,
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
import * as configModule from "../../../config/index.js";
|
|
56
|
+
import { stripeWebhookHandler } from "../stripe.js";
|
|
57
|
+
import { __resetIdempotencyStore } from "../../../lib/webhook-idempotency.js";
|
|
58
|
+
import { prisma } from "../../../lib/prisma.js";
|
|
59
|
+
|
|
60
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
const SESSION_ID = "cs_test_session_12345678901234567890";
|
|
63
|
+
const CUSTOMER_ID = "cus_test_12345678901234567890";
|
|
64
|
+
const EMAIL = "spoke-buyer@example.com";
|
|
65
|
+
const AMOUNT_TOTAL = 24917;
|
|
66
|
+
const USER_ID = "usr_buyer_7868";
|
|
67
|
+
const ORG_ID = "org_buyer_7868";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A session that reaches every one of the four `completed` tasks if it is
|
|
71
|
+
* dispatched: telemetry, the billing bind (customer + `ailk_user_id`), the
|
|
72
|
+
* recommender stamp (`flowCheckoutId`) and the request-paid stamp
|
|
73
|
+
* (`payment_link`, paid).
|
|
74
|
+
*/
|
|
75
|
+
function sessionEvent(
|
|
76
|
+
type: "checkout.session.completed" | "checkout.session.expired",
|
|
77
|
+
metadata: Record<string, unknown> = {},
|
|
78
|
+
) {
|
|
79
|
+
return {
|
|
80
|
+
id: "evt_test_12345678abcdef",
|
|
81
|
+
type,
|
|
82
|
+
data: {
|
|
83
|
+
object: {
|
|
84
|
+
id: SESSION_ID,
|
|
85
|
+
payment_status: "paid",
|
|
86
|
+
payment_link: "plink_test_1234567890",
|
|
87
|
+
amount_total: AMOUNT_TOTAL,
|
|
88
|
+
currency: "usd",
|
|
89
|
+
customer: CUSTOMER_ID,
|
|
90
|
+
customer_details: { email: EMAIL },
|
|
91
|
+
metadata: {
|
|
92
|
+
flowCheckoutId: "flchk_test_1",
|
|
93
|
+
flowSlug: "ailk-reco",
|
|
94
|
+
offerKey: "pro",
|
|
95
|
+
ailk_user_id: USER_ID,
|
|
96
|
+
...metadata,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type LogCall = [unknown, string?];
|
|
104
|
+
const logCalls: LogCall[] = [];
|
|
105
|
+
|
|
106
|
+
async function buildTestServer(): Promise<FastifyInstance> {
|
|
107
|
+
const server = Fastify({ logger: false });
|
|
108
|
+
await server.register(async (webhooksScope) => {
|
|
109
|
+
webhooksScope.addContentTypeParser("application/json", { parseAs: "buffer" }, (_req, body, done) => {
|
|
110
|
+
done(null, body);
|
|
111
|
+
});
|
|
112
|
+
webhooksScope.addHook("onRequest", async (request) => {
|
|
113
|
+
for (const level of ["info", "warn", "error"] as const) {
|
|
114
|
+
const original = request.log[level].bind(request.log);
|
|
115
|
+
request.log[level] = ((...args: unknown[]) => {
|
|
116
|
+
logCalls.push(args as LogCall);
|
|
117
|
+
return (original as (...a: unknown[]) => void)(...args);
|
|
118
|
+
}) as typeof request.log.info;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
webhooksScope.post("/v1/webhooks/stripe", stripeWebhookHandler);
|
|
122
|
+
});
|
|
123
|
+
await server.ready();
|
|
124
|
+
return server;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const VALID_BODY = Buffer.from(JSON.stringify({ type: "test" }));
|
|
128
|
+
const HEADERS = { "content-type": "application/json", "stripe-signature": "t=1234,v1=abc" };
|
|
129
|
+
const ORIGINAL_AILK_SITE_ID = process.env.AILK_SITE_ID;
|
|
130
|
+
|
|
131
|
+
let server: FastifyInstance;
|
|
132
|
+
|
|
133
|
+
beforeEach(async () => {
|
|
134
|
+
jest.clearAllMocks();
|
|
135
|
+
logCalls.length = 0;
|
|
136
|
+
__resetIdempotencyStore();
|
|
137
|
+
delete process.env.AILK_SITE_ID;
|
|
138
|
+
jest.replaceProperty(configModule, "config", {
|
|
139
|
+
...configModule.config,
|
|
140
|
+
STRIPE_WEBHOOK_SECRET: "whsec_test_secret",
|
|
141
|
+
});
|
|
142
|
+
(prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue({ amountMinor: AMOUNT_TOTAL });
|
|
143
|
+
(prisma.flowCheckout.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
|
|
144
|
+
(prisma.callback.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
|
|
145
|
+
(prisma.user.findFirst as jest.Mock).mockResolvedValue(null);
|
|
146
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
|
|
147
|
+
(prisma.organization.findFirst as jest.Mock).mockResolvedValue(null);
|
|
148
|
+
(prisma.organization.update as jest.Mock).mockResolvedValue({});
|
|
149
|
+
server = await buildTestServer();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
afterEach(async () => {
|
|
153
|
+
await server.close();
|
|
154
|
+
jest.restoreAllMocks();
|
|
155
|
+
if (ORIGINAL_AILK_SITE_ID === undefined) delete process.env.AILK_SITE_ID;
|
|
156
|
+
else process.env.AILK_SITE_ID = ORIGINAL_AILK_SITE_ID;
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
async function post() {
|
|
160
|
+
return server.inject({ method: "POST", url: "/v1/webhooks/stripe", payload: VALID_BODY, headers: HEADERS });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function foreignSiteLogs() {
|
|
164
|
+
return logCalls.filter(
|
|
165
|
+
([obj]) => typeof obj === "object" && obj !== null && (obj as Record<string, unknown>)["outcome"] === "foreign_site",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function expectNoHubSideEffect() {
|
|
170
|
+
expect(mockTrack).not.toHaveBeenCalled();
|
|
171
|
+
expect(prisma.flowCheckout.findUnique).not.toHaveBeenCalled();
|
|
172
|
+
expect(prisma.flowCheckout.updateMany).not.toHaveBeenCalled();
|
|
173
|
+
expect(prisma.callback.updateMany).not.toHaveBeenCalled();
|
|
174
|
+
expect(prisma.user.findFirst).not.toHaveBeenCalled();
|
|
175
|
+
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
176
|
+
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
177
|
+
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
|
178
|
+
expect(prisma.organization.findFirst).not.toHaveBeenCalled();
|
|
179
|
+
expect(prisma.organization.update).not.toHaveBeenCalled();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function expectHandledAsToday() {
|
|
183
|
+
expect(mockTrack).toHaveBeenCalledTimes(1);
|
|
184
|
+
expect(mockTrack).toHaveBeenCalledWith(
|
|
185
|
+
"purchase-completed",
|
|
186
|
+
expect.objectContaining({ amount: AMOUNT_TOTAL, eventType: "checkout.session.completed" }),
|
|
187
|
+
);
|
|
188
|
+
expect(prisma.flowCheckout.updateMany).toHaveBeenCalledTimes(1);
|
|
189
|
+
expect(prisma.callback.updateMany).toHaveBeenCalledTimes(1);
|
|
190
|
+
expect(prisma.user.findFirst).toHaveBeenCalledWith({
|
|
191
|
+
where: { billingProvider: "stripe", billingCustomerId: CUSTOMER_ID },
|
|
192
|
+
});
|
|
193
|
+
expect(foreignSiteLogs()).toHaveLength(0);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Case 1 — a foreign `completed` produces no hub side effect (AC-2) ───────
|
|
197
|
+
|
|
198
|
+
describe("case 1 — a completed session stamped for another site", () => {
|
|
199
|
+
it("answers 200 and runs none of the four tasks", async () => {
|
|
200
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
201
|
+
sessionEvent("checkout.session.completed", { site: "samhenry" }),
|
|
202
|
+
);
|
|
203
|
+
const res = await post();
|
|
204
|
+
|
|
205
|
+
expect(res.statusCode).toBe(200);
|
|
206
|
+
expect(JSON.parse(res.body)).toEqual({ received: true });
|
|
207
|
+
expectNoHubSideEffect();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("logs one foreign_site line carrying the stamp and the own site, and no buyer field or amount", async () => {
|
|
211
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
212
|
+
sessionEvent("checkout.session.completed", { site: "samhenry" }),
|
|
213
|
+
);
|
|
214
|
+
await post();
|
|
215
|
+
|
|
216
|
+
const lines = foreignSiteLogs();
|
|
217
|
+
expect(lines).toHaveLength(1);
|
|
218
|
+
expect(lines[0]![0]).toEqual({
|
|
219
|
+
eventType: "checkout.session.completed",
|
|
220
|
+
eventIdPrefix: "evt_test",
|
|
221
|
+
outcome: "foreign_site",
|
|
222
|
+
stamp: "samhenry",
|
|
223
|
+
ownSite: "default",
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const logged = JSON.stringify(logCalls);
|
|
227
|
+
expect(logged).not.toContain(EMAIL);
|
|
228
|
+
expect(logged).not.toContain(String(AMOUNT_TOTAL));
|
|
229
|
+
expect(logged).not.toContain(USER_ID);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("logs exactly the first 64 characters of a long foreign stamp", async () => {
|
|
233
|
+
const longStamp = "s".repeat(36) + "t".repeat(64);
|
|
234
|
+
expect(longStamp).toHaveLength(100);
|
|
235
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
236
|
+
sessionEvent("checkout.session.completed", { site: longStamp }),
|
|
237
|
+
);
|
|
238
|
+
const res = await post();
|
|
239
|
+
|
|
240
|
+
expect(res.statusCode).toBe(200);
|
|
241
|
+
expectNoHubSideEffect();
|
|
242
|
+
const lines = foreignSiteLogs();
|
|
243
|
+
expect(lines).toHaveLength(1);
|
|
244
|
+
expect((lines[0]![0] as Record<string, unknown>)["stamp"]).toBe(longStamp.slice(0, 64));
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ── Case 2 — a foreign session never reaches the Organization write ─────────
|
|
249
|
+
|
|
250
|
+
describe("case 2 — a foreign session carrying metadata.organizationId", () => {
|
|
251
|
+
it("writes no Organization", async () => {
|
|
252
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
253
|
+
sessionEvent("checkout.session.completed", { site: "samhenry", organizationId: ORG_ID }),
|
|
254
|
+
);
|
|
255
|
+
const res = await post();
|
|
256
|
+
|
|
257
|
+
expect(res.statusCode).toBe(200);
|
|
258
|
+
expectNoHubSideEffect();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ── Case 3 — a foreign `expired` stamps no FlowCheckout (AC-3) ──────────────
|
|
263
|
+
|
|
264
|
+
describe("case 3 — an expired session stamped for another site", () => {
|
|
265
|
+
it("answers 200 and never expires a hub FlowCheckout", async () => {
|
|
266
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
267
|
+
sessionEvent("checkout.session.expired", { site: "samhenry" }),
|
|
268
|
+
);
|
|
269
|
+
const res = await post();
|
|
270
|
+
|
|
271
|
+
expect(res.statusCode).toBe(200);
|
|
272
|
+
expect(JSON.parse(res.body)).toEqual({ received: true });
|
|
273
|
+
expect(prisma.flowCheckout.updateMany).not.toHaveBeenCalled();
|
|
274
|
+
const lines = foreignSiteLogs();
|
|
275
|
+
expect(lines).toHaveLength(1);
|
|
276
|
+
expect(lines[0]![0]).toMatchObject({
|
|
277
|
+
eventType: "checkout.session.expired",
|
|
278
|
+
stamp: "samhenry",
|
|
279
|
+
ownSite: "default",
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ── Cases 4–7 — own and unstamped sessions are handled as today (AC-4) ──────
|
|
285
|
+
|
|
286
|
+
describe("cases 4–7 — an unstamped, own-stamped or empty-stamped session is the hub's own", () => {
|
|
287
|
+
it("case 4 — an unstamped session runs every task, as today", async () => {
|
|
288
|
+
mockVerifyWebhookSignature.mockReturnValue(sessionEvent("checkout.session.completed"));
|
|
289
|
+
const res = await post();
|
|
290
|
+
|
|
291
|
+
expect(res.statusCode).toBe(200);
|
|
292
|
+
expectHandledAsToday();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("case 5 — a session stamped with the default alias, AILK_SITE_ID unset, runs every task", async () => {
|
|
296
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
297
|
+
sessionEvent("checkout.session.completed", { site: "default" }),
|
|
298
|
+
);
|
|
299
|
+
const res = await post();
|
|
300
|
+
|
|
301
|
+
expect(res.statusCode).toBe(200);
|
|
302
|
+
expectHandledAsToday();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("case 6 — under AILK_SITE_ID=wksp_hub, a wksp_hub stamp is own", async () => {
|
|
306
|
+
process.env.AILK_SITE_ID = "wksp_hub";
|
|
307
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
308
|
+
sessionEvent("checkout.session.completed", { site: "wksp_hub" }),
|
|
309
|
+
);
|
|
310
|
+
const res = await post();
|
|
311
|
+
|
|
312
|
+
expect(res.statusCode).toBe(200);
|
|
313
|
+
expectHandledAsToday();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("case 6 — under AILK_SITE_ID=wksp_hub, a default stamp is foreign", async () => {
|
|
317
|
+
process.env.AILK_SITE_ID = "wksp_hub";
|
|
318
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
319
|
+
sessionEvent("checkout.session.completed", { site: "default" }),
|
|
320
|
+
);
|
|
321
|
+
const res = await post();
|
|
322
|
+
|
|
323
|
+
expect(res.statusCode).toBe(200);
|
|
324
|
+
expectNoHubSideEffect();
|
|
325
|
+
const lines = foreignSiteLogs();
|
|
326
|
+
expect(lines).toHaveLength(1);
|
|
327
|
+
expect(lines[0]![0]).toMatchObject({ stamp: "default", ownSite: "wksp_hub" });
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("case 7 — an empty stamp is unstamped, and runs every task", async () => {
|
|
331
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
332
|
+
sessionEvent("checkout.session.completed", { site: "" }),
|
|
333
|
+
);
|
|
334
|
+
const res = await post();
|
|
335
|
+
|
|
336
|
+
expect(res.statusCode).toBe(200);
|
|
337
|
+
expectHandledAsToday();
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// ── Case 8 — the event-id dedup is unchanged ────────────────────────────────
|
|
342
|
+
|
|
343
|
+
describe("case 8 — a foreign event delivered twice", () => {
|
|
344
|
+
it("is marked seen, so the redelivery takes the duplicate short-circuit", async () => {
|
|
345
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
346
|
+
sessionEvent("checkout.session.completed", { site: "samhenry" }),
|
|
347
|
+
);
|
|
348
|
+
const first = await post();
|
|
349
|
+
expect(first.statusCode).toBe(200);
|
|
350
|
+
expect(foreignSiteLogs()).toHaveLength(1);
|
|
351
|
+
|
|
352
|
+
// Each delivery's lines are read on their own: the harness re-wraps the
|
|
353
|
+
// shared request logger per request, so a second request logs each line
|
|
354
|
+
// more than once.
|
|
355
|
+
logCalls.length = 0;
|
|
356
|
+
const second = await post();
|
|
357
|
+
|
|
358
|
+
expect(second.statusCode).toBe(200);
|
|
359
|
+
expect(JSON.parse(second.body)).toEqual({ received: true });
|
|
360
|
+
expect(foreignSiteLogs()).toHaveLength(0);
|
|
361
|
+
expect(logCalls.map(([, msg]) => msg)).toContain(
|
|
362
|
+
"duplicate stripe webhook event — idempotent short-circuit",
|
|
363
|
+
);
|
|
364
|
+
expectNoHubSideEffect();
|
|
365
|
+
});
|
|
366
|
+
});
|
|
@@ -31,6 +31,15 @@
|
|
|
31
31
|
* Successful dispatch always 200s — including unknown event types.
|
|
32
32
|
* Telemetry failures are caught, logged, and do NOT change the response.
|
|
33
33
|
*
|
|
34
|
+
* Site filter (#7868):
|
|
35
|
+
* The Stripe account is shared with spoke sites, so this endpoint receives
|
|
36
|
+
* their Checkout sessions too. A `checkout.session.completed` or `.expired`
|
|
37
|
+
* whose `metadata.site` is a non-empty string naming another site
|
|
38
|
+
* (`isForeignSiteStamp`, lib/site.ts) takes no action: it logs
|
|
39
|
+
* `outcome: "foreign_site"` and answers 200. An unstamped session is the
|
|
40
|
+
* hub's own (roadmap D4) and is handled as before. The hub stamps its own
|
|
41
|
+
* sessions through `siteStamp()`. See README.md § Site filter (#7868).
|
|
42
|
+
*
|
|
34
43
|
* Billing upsert (customer.created / checkout.session.completed):
|
|
35
44
|
* Billing-entity routing (org-scoped billing, #3601, spec D2): a checkout
|
|
36
45
|
* carrying metadata.organizationId links the Organization (multi-tenant
|
|
@@ -61,6 +70,12 @@
|
|
|
61
70
|
* `"no_payment_required"` (a 100%-off promo code, #4862) — anything else
|
|
62
71
|
* logs `outcome: "unpaid"` and leaves the row `created`.
|
|
63
72
|
*
|
|
73
|
+
* Checkout Person (checkout.session.completed, #852 D6):
|
|
74
|
+
* Chained after that stamp, `recordFlowCheckoutPerson` finds or creates the
|
|
75
|
+
* Person of the checkout row THIS session completed (matched on its own
|
|
76
|
+
* `stripeSessionId`), with `session.customer` as the customer id only on a
|
|
77
|
+
* live-mode event. Logs an outcome kind only; never fails the 200.
|
|
78
|
+
*
|
|
64
79
|
* Dunning (invoice.payment_failed / customer.subscription.updated, #6034):
|
|
65
80
|
* Records a per-subscriber lifecycle state in a `UserData` row behind a
|
|
66
81
|
* reserved-key guard (routes/auth/user.ts). `invoice.payment_failed` arms
|
|
@@ -83,7 +98,11 @@ import {
|
|
|
83
98
|
LICENSE_UNDETERMINED_RETRY_AFTER_SECONDS,
|
|
84
99
|
LICENSE_UNDETERMINED_STATUS,
|
|
85
100
|
} from "../../lib/require-commerce-claim.js";
|
|
86
|
-
import {
|
|
101
|
+
import {
|
|
102
|
+
currentSiteId,
|
|
103
|
+
isForeignSiteStamp,
|
|
104
|
+
SITE_STAMP_KEY,
|
|
105
|
+
} from "../../lib/site.js";
|
|
87
106
|
import {
|
|
88
107
|
StripeWebhookSignatureError,
|
|
89
108
|
toSubscriptionLifecycleStatus,
|
|
@@ -98,6 +117,10 @@ import {
|
|
|
98
117
|
type DunningEntry,
|
|
99
118
|
type DunningRecord,
|
|
100
119
|
} from "../../services/dunning-policy.js";
|
|
120
|
+
import {
|
|
121
|
+
recordCheckoutPerson,
|
|
122
|
+
type CheckoutPersonOutcome,
|
|
123
|
+
} from "../../services/person.js";
|
|
101
124
|
|
|
102
125
|
const TELEMETRY_TIMEOUT_MS = 2000;
|
|
103
126
|
const DUNNING_WRITE_MAX_ATTEMPTS = 3;
|
|
@@ -299,6 +322,14 @@ export const stripeWebhookHandler = async (
|
|
|
299
322
|
}
|
|
300
323
|
case "checkout.session.completed": {
|
|
301
324
|
const session = event.data.object as CheckoutSessionObject;
|
|
325
|
+
if (isForeignSiteStamp(session.metadata)) {
|
|
326
|
+
logForeignSiteSkip(request, {
|
|
327
|
+
stamp: session.metadata?.[SITE_STAMP_KEY],
|
|
328
|
+
eventType: event.type,
|
|
329
|
+
eventIdPrefix,
|
|
330
|
+
});
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
302
333
|
const props = {
|
|
303
334
|
amount: session.amount_total ?? undefined,
|
|
304
335
|
currency: session.currency ?? undefined,
|
|
@@ -338,11 +369,19 @@ export const stripeWebhookHandler = async (
|
|
|
338
369
|
await Promise.all([
|
|
339
370
|
fireTelemetryWithTimeout(request, "purchase-completed", props),
|
|
340
371
|
billingTask,
|
|
372
|
+
// #852 D6 — the Person step runs only after the stamp has settled,
|
|
373
|
+
// so it reads the row this very session completed.
|
|
341
374
|
stampFlowCheckout(request, {
|
|
342
375
|
session,
|
|
343
376
|
eventIdPrefix,
|
|
344
377
|
outcome: "completed",
|
|
345
|
-
})
|
|
378
|
+
}).then(() =>
|
|
379
|
+
recordFlowCheckoutPerson(request, {
|
|
380
|
+
session,
|
|
381
|
+
livemode: event.livemode === true,
|
|
382
|
+
eventIdPrefix,
|
|
383
|
+
}),
|
|
384
|
+
),
|
|
346
385
|
stampRequestPaid(request, { session, eventIdPrefix }),
|
|
347
386
|
]);
|
|
348
387
|
break;
|
|
@@ -363,6 +402,14 @@ export const stripeWebhookHandler = async (
|
|
|
363
402
|
}
|
|
364
403
|
case "checkout.session.expired": {
|
|
365
404
|
const session = event.data.object as CheckoutSessionObject;
|
|
405
|
+
if (isForeignSiteStamp(session.metadata)) {
|
|
406
|
+
logForeignSiteSkip(request, {
|
|
407
|
+
stamp: session.metadata?.[SITE_STAMP_KEY],
|
|
408
|
+
eventType: event.type,
|
|
409
|
+
eventIdPrefix,
|
|
410
|
+
});
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
366
413
|
if (!session.metadata?.["flowCheckoutId"]) {
|
|
367
414
|
// Not a recommender session — the same low-noise line the default
|
|
368
415
|
// arm emitted for this event before #4838 handled it.
|
|
@@ -432,6 +479,37 @@ type CheckoutSessionObject = {
|
|
|
432
479
|
metadata?: Record<string, string> | null;
|
|
433
480
|
};
|
|
434
481
|
|
|
482
|
+
/**
|
|
483
|
+
* The one log line for a session another site stamped (#7868 D3). Carries the
|
|
484
|
+
* foreign stamp (first 64 characters) and this site's own alias, so a spoke
|
|
485
|
+
* event correctly skipped can be told from a hub event whose alias drifted —
|
|
486
|
+
* site labels only: it takes the stamp, never the session, so no buyer field
|
|
487
|
+
* or amount can reach it.
|
|
488
|
+
*/
|
|
489
|
+
function logForeignSiteSkip(
|
|
490
|
+
request: FastifyRequest,
|
|
491
|
+
{
|
|
492
|
+
stamp,
|
|
493
|
+
eventType,
|
|
494
|
+
eventIdPrefix,
|
|
495
|
+
}: {
|
|
496
|
+
stamp: string | undefined;
|
|
497
|
+
eventType: string;
|
|
498
|
+
eventIdPrefix: string;
|
|
499
|
+
},
|
|
500
|
+
): void {
|
|
501
|
+
request.log.info(
|
|
502
|
+
{
|
|
503
|
+
eventType,
|
|
504
|
+
eventIdPrefix,
|
|
505
|
+
outcome: "foreign_site",
|
|
506
|
+
stamp: stamp?.slice(0, 64),
|
|
507
|
+
ownSite: currentSiteId(),
|
|
508
|
+
},
|
|
509
|
+
"checkout session stamped for another site — skipped",
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
435
513
|
/**
|
|
436
514
|
* Minimal projected shape of a Stripe Invoice object for the dunning arms
|
|
437
515
|
* (#6034 FR-1). `subscription` and `customer` are EXPANDABLE Stripe fields —
|
|
@@ -563,6 +641,59 @@ async function stampFlowCheckout(
|
|
|
563
641
|
}
|
|
564
642
|
}
|
|
565
643
|
|
|
644
|
+
/**
|
|
645
|
+
* The hub checkout's Person (#852 D6): after `stampFlowCheckout`, find or
|
|
646
|
+
* create the Person of the completed checkout's lead
|
|
647
|
+
* (`services/person.ts`'s `recordCheckoutPerson`). Runs only for a paid (or
|
|
648
|
+
* nothing-due) session of THIS site — the foreign-site filter has already
|
|
649
|
+
* returned — that carries `metadata.flowCheckoutId`. The customer id's ONE
|
|
650
|
+
* source is `session.customer`, written onto the Person only for a
|
|
651
|
+
* live-mode event (SEC-852-A). A replay matches the same completed row, so a
|
|
652
|
+
* delivery whose Person step failed is repaired by the next one.
|
|
653
|
+
*
|
|
654
|
+
* Logs `{ eventIdPrefix, flowCheckoutIdPrefix, outcome }` only — never the
|
|
655
|
+
* email or the customer id. Never throws: the response stays 200.
|
|
656
|
+
*/
|
|
657
|
+
async function recordFlowCheckoutPerson(
|
|
658
|
+
request: FastifyRequest,
|
|
659
|
+
{
|
|
660
|
+
session,
|
|
661
|
+
livemode,
|
|
662
|
+
eventIdPrefix,
|
|
663
|
+
}: {
|
|
664
|
+
session: CheckoutSessionObject;
|
|
665
|
+
livemode: boolean;
|
|
666
|
+
eventIdPrefix: string;
|
|
667
|
+
},
|
|
668
|
+
): Promise<void> {
|
|
669
|
+
const flowCheckoutId = session.metadata?.["flowCheckoutId"];
|
|
670
|
+
if (!flowCheckoutId) return;
|
|
671
|
+
if (
|
|
672
|
+
session.payment_status !== "paid" &&
|
|
673
|
+
session.payment_status !== "no_payment_required"
|
|
674
|
+
) {
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const flowCheckoutIdPrefix = flowCheckoutId.substring(0, 8);
|
|
678
|
+
let outcome: CheckoutPersonOutcome;
|
|
679
|
+
try {
|
|
680
|
+
outcome = await recordCheckoutPerson({
|
|
681
|
+
flowCheckoutId,
|
|
682
|
+
stripeSessionId: session.id,
|
|
683
|
+
customer: session.customer,
|
|
684
|
+
livemode,
|
|
685
|
+
});
|
|
686
|
+
} catch {
|
|
687
|
+
outcome = "person_failed";
|
|
688
|
+
}
|
|
689
|
+
const line = { eventIdPrefix, flowCheckoutIdPrefix, outcome };
|
|
690
|
+
if (outcome === "person_failed") {
|
|
691
|
+
request.log.error(line, "flow checkout person failed — continuing");
|
|
692
|
+
} else {
|
|
693
|
+
request.log.info(line, "flow checkout person");
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
566
697
|
/**
|
|
567
698
|
* Stamp `paidAt` on a request-and-confirm callback row (#5970 AC-3).
|
|
568
699
|
*
|