@open-mercato/checkout 0.6.7-develop.6696.1.4236709f7e → 0.6.7-develop.6725.1.07a748cfc2

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 (25) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/checkout/__integration__/TC-CHKT-042.spec.js +39 -0
  3. package/dist/modules/checkout/__integration__/TC-CHKT-042.spec.js.map +7 -0
  4. package/dist/modules/checkout/api/transactions/route.js +23 -6
  5. package/dist/modules/checkout/api/transactions/route.js.map +2 -2
  6. package/dist/modules/checkout/search.js +30 -0
  7. package/dist/modules/checkout/search.js.map +2 -2
  8. package/dist/modules/checkout/setup.js +28 -0
  9. package/dist/modules/checkout/setup.js.map +2 -2
  10. package/dist/modules/checkout/subscribers/search-reindex-transaction-created.js +32 -0
  11. package/dist/modules/checkout/subscribers/search-reindex-transaction-created.js.map +7 -0
  12. package/dist/modules/checkout/workers/transaction-expiry.worker.js +9 -2
  13. package/dist/modules/checkout/workers/transaction-expiry.worker.js.map +2 -2
  14. package/package.json +5 -5
  15. package/src/modules/checkout/__integration__/TC-CHKT-042.spec.ts +41 -0
  16. package/src/modules/checkout/__tests__/search.test.ts +21 -0
  17. package/src/modules/checkout/__tests__/setup-schedules.test.ts +99 -0
  18. package/src/modules/checkout/api/transactions/__tests__/route.test.ts +113 -0
  19. package/src/modules/checkout/api/transactions/route.ts +30 -6
  20. package/src/modules/checkout/search.ts +30 -0
  21. package/src/modules/checkout/setup.ts +51 -0
  22. package/src/modules/checkout/subscribers/__tests__/search-reindex-transaction-created.test.ts +29 -0
  23. package/src/modules/checkout/subscribers/search-reindex-transaction-created.ts +39 -0
  24. package/src/modules/checkout/workers/__tests__/transaction-expiry.worker.test.ts +124 -0
  25. package/src/modules/checkout/workers/transaction-expiry.worker.ts +11 -0
@@ -1,2 +1,2 @@
1
- [build:checkout] found 157 entry points
1
+ [build:checkout] found 159 entry points
2
2
  [build:checkout] built successfully
@@ -0,0 +1,39 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { getAuthToken } from "@open-mercato/core/modules/core/__integration__/helpers/api";
3
+ import {
4
+ createCustomerData,
5
+ createFixedTemplateInput,
6
+ createLinkFixture,
7
+ deleteCheckoutEntityIfExists,
8
+ listCheckoutTransactions,
9
+ submitPayLink
10
+ } from "./helpers/fixtures.js";
11
+ test.describe("TC-CHKT-042: Transaction search over encrypted customer data", () => {
12
+ test("finds a newly created transaction through its token index", async ({ request }) => {
13
+ const email = `token-search-${Date.now()}@example.test`;
14
+ let token = null;
15
+ let linkId = null;
16
+ try {
17
+ token = await getAuthToken(request);
18
+ const link = await createLinkFixture(request, token, createFixedTemplateInput({ status: "active" }));
19
+ linkId = link.id;
20
+ const submitted = await submitPayLink(request, link.slug, {
21
+ customerData: createCustomerData({ email }),
22
+ acceptedLegalConsents: {},
23
+ amount: 49.99
24
+ });
25
+ expect(submitted.ok()).toBeTruthy();
26
+ await expect.poll(async () => {
27
+ const result = await listCheckoutTransactions(
28
+ request,
29
+ token,
30
+ `search=${encodeURIComponent(email)}&page=1&pageSize=10`
31
+ );
32
+ return result.items.some((item) => item.email === email);
33
+ }, { timeout: 15e3 }).toBeTruthy();
34
+ } finally {
35
+ await deleteCheckoutEntityIfExists(request, token, "links", linkId);
36
+ }
37
+ });
38
+ });
39
+ //# sourceMappingURL=TC-CHKT-042.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/checkout/__integration__/TC-CHKT-042.spec.ts"],
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { getAuthToken } from '@open-mercato/core/modules/core/__integration__/helpers/api'\nimport {\n createCustomerData,\n createFixedTemplateInput,\n createLinkFixture,\n deleteCheckoutEntityIfExists,\n listCheckoutTransactions,\n submitPayLink,\n} from './helpers/fixtures'\n\ntest.describe('TC-CHKT-042: Transaction search over encrypted customer data', () => {\n test('finds a newly created transaction through its token index', async ({ request }) => {\n const email = `token-search-${Date.now()}@example.test`\n let token: string | null = null\n let linkId: string | null = null\n\n try {\n token = await getAuthToken(request)\n const link = await createLinkFixture(request, token, createFixedTemplateInput({ status: 'active' }))\n linkId = link.id\n const submitted = await submitPayLink(request, link.slug, {\n customerData: createCustomerData({ email }),\n acceptedLegalConsents: {},\n amount: 49.99,\n })\n expect(submitted.ok()).toBeTruthy()\n\n await expect.poll(async () => {\n const result = await listCheckoutTransactions(\n request,\n token!,\n `search=${encodeURIComponent(email)}&page=1&pageSize=10`,\n )\n return result.items.some((item) => item.email === email)\n }, { timeout: 15_000 }).toBeTruthy()\n } finally {\n await deleteCheckoutEntityIfExists(request, token, 'links', linkId)\n }\n })\n})\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,KAAK,SAAS,gEAAgE,MAAM;AAClF,OAAK,6DAA6D,OAAO,EAAE,QAAQ,MAAM;AACvF,UAAM,QAAQ,gBAAgB,KAAK,IAAI,CAAC;AACxC,QAAI,QAAuB;AAC3B,QAAI,SAAwB;AAE5B,QAAI;AACF,cAAQ,MAAM,aAAa,OAAO;AAClC,YAAM,OAAO,MAAM,kBAAkB,SAAS,OAAO,yBAAyB,EAAE,QAAQ,SAAS,CAAC,CAAC;AACnG,eAAS,KAAK;AACd,YAAM,YAAY,MAAM,cAAc,SAAS,KAAK,MAAM;AAAA,QACxD,cAAc,mBAAmB,EAAE,MAAM,CAAC;AAAA,QAC1C,uBAAuB,CAAC;AAAA,QACxB,QAAQ;AAAA,MACV,CAAC;AACD,aAAO,UAAU,GAAG,CAAC,EAAE,WAAW;AAElC,YAAM,OAAO,KAAK,YAAY;AAC5B,cAAM,SAAS,MAAM;AAAA,UACnB;AAAA,UACA;AAAA,UACA,UAAU,mBAAmB,KAAK,CAAC;AAAA,QACrC;AACA,eAAO,OAAO,MAAM,KAAK,CAAC,SAAS,KAAK,UAAU,KAAK;AAAA,MACzD,GAAG,EAAE,SAAS,KAAO,CAAC,EAAE,WAAW;AAAA,IACrC,UAAE;AACA,YAAM,6BAA6B,SAAS,OAAO,SAAS,MAAM;AAAA,IACpE;AAAA,EACF,CAAC;AACH,CAAC;",
6
+ "names": []
7
+ }
@@ -1,10 +1,13 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { findAndCountWithDecryption, findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
3
3
  import { escapeLikePattern } from "@open-mercato/shared/lib/db/escapeLikePattern";
4
+ import { findEntityIdsBySearchTokens } from "@open-mercato/shared/lib/search/tokenLookup";
4
5
  import { CheckoutLink, CheckoutTransaction } from "../../data/entities.js";
6
+ import { CHECKOUT_ENTITY_IDS } from "../../lib/constants.js";
5
7
  import { handleCheckoutRouteError, requireAdminContext, userHasCheckoutFeature } from "../helpers.js";
6
8
  import { checkoutTag } from "../openapi.js";
7
9
  import { serializeTransaction } from "../../lib/utils.js";
10
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
8
11
  const metadata = {
9
12
  path: "/checkout/transactions",
10
13
  GET: { requireAuth: true, requireFeatures: ["checkout.view"] }
@@ -25,12 +28,26 @@ async function GET(req) {
25
28
  };
26
29
  if (linkId) where.linkId = linkId;
27
30
  if (status) where.status = status;
28
- if (search) where.$or = [
29
- { email: { $ilike: `%${escapeLikePattern(search)}%` } },
30
- { firstName: { $ilike: `%${escapeLikePattern(search)}%` } },
31
- { lastName: { $ilike: `%${escapeLikePattern(search)}%` } },
32
- { id: { $ilike: `%${escapeLikePattern(search)}%` } }
33
- ];
31
+ if (search) {
32
+ const pattern = `%${escapeLikePattern(search)}%`;
33
+ const searchOr = [
34
+ { email: { $ilike: pattern } },
35
+ { firstName: { $ilike: pattern } },
36
+ { lastName: { $ilike: pattern } }
37
+ ];
38
+ if (UUID_PATTERN.test(search)) searchOr.push({ id: search });
39
+ const tokenMatch = await findEntityIdsBySearchTokens({
40
+ db: em.getKysely(),
41
+ entityType: CHECKOUT_ENTITY_IDS.transaction,
42
+ query: search,
43
+ fields: ["email", "first_name", "last_name"],
44
+ scope: { tenantId: auth.tenantId, organizationId: auth.orgId }
45
+ });
46
+ if (tokenMatch.matched && tokenMatch.ids.length) {
47
+ searchOr.push({ id: { $in: tokenMatch.ids } });
48
+ }
49
+ where.$or = searchOr;
50
+ }
34
51
  const [items, total] = await findAndCountWithDecryption(
35
52
  em,
36
53
  CheckoutTransaction,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/checkout/api/transactions/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { findAndCountWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport { CheckoutLink, CheckoutTransaction } from '../../data/entities'\nimport { handleCheckoutRouteError, requireAdminContext, userHasCheckoutFeature } from '../helpers'\nimport { checkoutTag } from '../openapi'\nimport { serializeTransaction } from '../../lib/utils'\n\nexport const metadata = {\n path: '/checkout/transactions',\n GET: { requireAuth: true, requireFeatures: ['checkout.view'] },\n}\n\nexport async function GET(req: Request) {\n try {\n const { auth, container, em } = await requireAdminContext(req)\n const canViewPii = await userHasCheckoutFeature(container, auth, 'checkout.viewPii')\n const url = new URL(req.url)\n const page = Math.max(1, Number(url.searchParams.get('page') ?? '1'))\n const pageSize = Math.min(100, Math.max(1, Number(url.searchParams.get('pageSize') ?? '25')))\n const search = (url.searchParams.get('search') ?? '').trim()\n const linkId = url.searchParams.get('linkId')\n const status = url.searchParams.get('status')\n const where: Record<string, unknown> = {\n organizationId: auth.orgId,\n tenantId: auth.tenantId,\n }\n if (linkId) where.linkId = linkId\n if (status) where.status = status\n if (search) where.$or = [\n { email: { $ilike: `%${escapeLikePattern(search)}%` } },\n { firstName: { $ilike: `%${escapeLikePattern(search)}%` } },\n { lastName: { $ilike: `%${escapeLikePattern(search)}%` } },\n { id: { $ilike: `%${escapeLikePattern(search)}%` } },\n ]\n const [items, total] = await findAndCountWithDecryption(\n em,\n CheckoutTransaction,\n where,\n {\n orderBy: { createdAt: 'desc' },\n offset: (page - 1) * pageSize,\n limit: pageSize,\n },\n { organizationId: auth.orgId, tenantId: auth.tenantId },\n )\n const linkIds = Array.from(new Set(items.map((item) => item.linkId)))\n const links = linkIds.length\n ? await findWithDecryption(em, CheckoutLink, {\n id: { $in: linkIds },\n organizationId: auth.orgId,\n tenantId: auth.tenantId,\n deletedAt: null,\n }, undefined, { organizationId: auth.orgId, tenantId: auth.tenantId })\n : []\n const linkMap = new Map(links.map((link) => [link.id, link]))\n return NextResponse.json({\n items: items.map((item) => serializeTransaction(item, linkMap.get(item.linkId) ?? null, canViewPii)),\n total,\n page,\n pageSize,\n totalPages: Math.max(1, Math.ceil(total / pageSize)),\n canViewPii,\n })\n } catch (error) {\n return handleCheckoutRouteError(error)\n }\n}\n\nexport const openApi = {\n tags: [checkoutTag],\n}\n\nexport default GET\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { findAndCountWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport { findEntityIdsBySearchTokens, type SearchTokenDatabase } from '@open-mercato/shared/lib/search/tokenLookup'\nimport { CheckoutLink, CheckoutTransaction } from '../../data/entities'\nimport { CHECKOUT_ENTITY_IDS } from '../../lib/constants'\nimport { handleCheckoutRouteError, requireAdminContext, userHasCheckoutFeature } from '../helpers'\nimport { checkoutTag } from '../openapi'\nimport { serializeTransaction } from '../../lib/utils'\n\nconst UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i\n\nexport const metadata = {\n path: '/checkout/transactions',\n GET: { requireAuth: true, requireFeatures: ['checkout.view'] },\n}\n\nexport async function GET(req: Request) {\n try {\n const { auth, container, em } = await requireAdminContext(req)\n const canViewPii = await userHasCheckoutFeature(container, auth, 'checkout.viewPii')\n const url = new URL(req.url)\n const page = Math.max(1, Number(url.searchParams.get('page') ?? '1'))\n const pageSize = Math.min(100, Math.max(1, Number(url.searchParams.get('pageSize') ?? '25')))\n const search = (url.searchParams.get('search') ?? '').trim()\n const linkId = url.searchParams.get('linkId')\n const status = url.searchParams.get('status')\n const where: Record<string, unknown> = {\n organizationId: auth.orgId,\n tenantId: auth.tenantId,\n }\n if (linkId) where.linkId = linkId\n if (status) where.status = status\n if (search) {\n const pattern = `%${escapeLikePattern(search)}%`\n // email/first_name/last_name are covered by the checkout encryption map,\n // so an ILIKE alone compares the pattern against ciphertext and matches\n // nothing once encryption is on. Union it with the token index, which\n // stores hashes of the plaintext. Issue #2990.\n const searchOr: Record<string, unknown>[] = [\n { email: { $ilike: pattern } },\n { firstName: { $ilike: pattern } },\n { lastName: { $ilike: pattern } },\n ]\n // `id` is a uuid column: ILIKE against it has no Postgres operator and\n // raises 42883, so only an exact id lookup is meaningful here.\n if (UUID_PATTERN.test(search)) searchOr.push({ id: search })\n const tokenMatch = await findEntityIdsBySearchTokens({\n db: em.getKysely<SearchTokenDatabase>(),\n entityType: CHECKOUT_ENTITY_IDS.transaction,\n query: search,\n fields: ['email', 'first_name', 'last_name'],\n scope: { tenantId: auth.tenantId, organizationId: auth.orgId },\n })\n if (tokenMatch.matched && tokenMatch.ids.length) {\n searchOr.push({ id: { $in: tokenMatch.ids } })\n }\n where.$or = searchOr\n }\n const [items, total] = await findAndCountWithDecryption(\n em,\n CheckoutTransaction,\n where,\n {\n orderBy: { createdAt: 'desc' },\n offset: (page - 1) * pageSize,\n limit: pageSize,\n },\n { organizationId: auth.orgId, tenantId: auth.tenantId },\n )\n const linkIds = Array.from(new Set(items.map((item) => item.linkId)))\n const links = linkIds.length\n ? await findWithDecryption(em, CheckoutLink, {\n id: { $in: linkIds },\n organizationId: auth.orgId,\n tenantId: auth.tenantId,\n deletedAt: null,\n }, undefined, { organizationId: auth.orgId, tenantId: auth.tenantId })\n : []\n const linkMap = new Map(links.map((link) => [link.id, link]))\n return NextResponse.json({\n items: items.map((item) => serializeTransaction(item, linkMap.get(item.linkId) ?? null, canViewPii)),\n total,\n page,\n pageSize,\n totalPages: Math.max(1, Math.ceil(total / pageSize)),\n canViewPii,\n })\n } catch (error) {\n return handleCheckoutRouteError(error)\n }\n}\n\nexport const openApi = {\n tags: [checkoutTag],\n}\n\nexport default GET\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -41,6 +41,36 @@ const searchConfig = {
41
41
  subtitle: "Link Template",
42
42
  icon: "lucide:file-text"
43
43
  })
44
+ },
45
+ {
46
+ entityId: CHECKOUT_ENTITY_IDS.transaction,
47
+ enabled: true,
48
+ priority: 6,
49
+ fieldPolicy: {
50
+ searchable: ["email", "firstName", "lastName"]
51
+ },
52
+ buildSource: async (ctx) => ({
53
+ text: [
54
+ asSearchText(ctx.record.email),
55
+ asSearchText(ctx.record.firstName),
56
+ asSearchText(ctx.record.lastName)
57
+ ],
58
+ fields: {
59
+ email: ctx.record.email,
60
+ first_name: ctx.record.firstName,
61
+ last_name: ctx.record.lastName
62
+ },
63
+ presenter: {
64
+ title: asSearchText(ctx.record.email),
65
+ subtitle: `${asSearchText(ctx.record.firstName)} ${asSearchText(ctx.record.lastName)}`.trim()
66
+ },
67
+ checksumSource: { record: ctx.record }
68
+ }),
69
+ formatResult: async (ctx) => ({
70
+ title: asSearchText(ctx.record.email),
71
+ subtitle: `${asSearchText(ctx.record.firstName)} ${asSearchText(ctx.record.lastName)}`.trim(),
72
+ icon: "lucide:receipt"
73
+ })
44
74
  }
45
75
  ]
46
76
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/checkout/search.ts"],
4
- "sourcesContent": ["import type { SearchModuleConfig } from '@open-mercato/shared/modules/search'\nimport { CHECKOUT_ENTITY_IDS } from './lib/constants'\n\nfunction asSearchText(value: unknown): string {\n return typeof value === 'string' ? value : ''\n}\n\nexport const searchConfig: SearchModuleConfig = {\n entities: [\n {\n entityId: CHECKOUT_ENTITY_IDS.link,\n enabled: true,\n priority: 10,\n fieldPolicy: {\n searchable: ['name', 'title', 'slug'],\n excluded: ['passwordHash', 'gatewaySettings', 'customerFieldsSchema'],\n },\n buildSource: async (ctx) => ({\n text: [`${asSearchText(ctx.record.name)}: ${asSearchText(ctx.record.title)} (${asSearchText(ctx.record.slug)})`],\n presenter: { title: asSearchText(ctx.record.name), subtitle: asSearchText(ctx.record.slug) },\n checksumSource: { record: ctx.record, customFields: ctx.customFields },\n }),\n formatResult: async (ctx) => ({\n title: asSearchText(ctx.record.name),\n subtitle: `/pay/${asSearchText(ctx.record.slug)}`,\n icon: 'lucide:link',\n }),\n },\n {\n entityId: CHECKOUT_ENTITY_IDS.template,\n enabled: true,\n priority: 8,\n fieldPolicy: {\n searchable: ['name', 'title'],\n excluded: ['passwordHash', 'gatewaySettings', 'customerFieldsSchema'],\n },\n buildSource: async (ctx) => ({\n text: [`${asSearchText(ctx.record.name)}: ${asSearchText(ctx.record.title)}`],\n presenter: { title: asSearchText(ctx.record.name), subtitle: 'Link Template' },\n checksumSource: { record: ctx.record, customFields: ctx.customFields },\n }),\n formatResult: async (ctx) => ({\n title: asSearchText(ctx.record.name),\n subtitle: 'Link Template',\n icon: 'lucide:file-text',\n }),\n },\n ],\n}\n\nexport const config = searchConfig\nexport default searchConfig\n"],
5
- "mappings": "AACA,SAAS,2BAA2B;AAEpC,SAAS,aAAa,OAAwB;AAC5C,SAAO,OAAO,UAAU,WAAW,QAAQ;AAC7C;AAEO,MAAM,eAAmC;AAAA,EAC9C,UAAU;AAAA,IACR;AAAA,MACE,UAAU,oBAAoB;AAAA,MAC9B,SAAS;AAAA,MACT,UAAU;AAAA,MACV,aAAa;AAAA,QACX,YAAY,CAAC,QAAQ,SAAS,MAAM;AAAA,QACpC,UAAU,CAAC,gBAAgB,mBAAmB,sBAAsB;AAAA,MACtE;AAAA,MACA,aAAa,OAAO,SAAS;AAAA,QAC3B,MAAM,CAAC,GAAG,aAAa,IAAI,OAAO,IAAI,CAAC,KAAK,aAAa,IAAI,OAAO,KAAK,CAAC,KAAK,aAAa,IAAI,OAAO,IAAI,CAAC,GAAG;AAAA,QAC/G,WAAW,EAAE,OAAO,aAAa,IAAI,OAAO,IAAI,GAAG,UAAU,aAAa,IAAI,OAAO,IAAI,EAAE;AAAA,QAC3F,gBAAgB,EAAE,QAAQ,IAAI,QAAQ,cAAc,IAAI,aAAa;AAAA,MACvE;AAAA,MACA,cAAc,OAAO,SAAS;AAAA,QAC5B,OAAO,aAAa,IAAI,OAAO,IAAI;AAAA,QACnC,UAAU,QAAQ,aAAa,IAAI,OAAO,IAAI,CAAC;AAAA,QAC/C,MAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA;AAAA,MACE,UAAU,oBAAoB;AAAA,MAC9B,SAAS;AAAA,MACT,UAAU;AAAA,MACV,aAAa;AAAA,QACX,YAAY,CAAC,QAAQ,OAAO;AAAA,QAC5B,UAAU,CAAC,gBAAgB,mBAAmB,sBAAsB;AAAA,MACtE;AAAA,MACA,aAAa,OAAO,SAAS;AAAA,QAC3B,MAAM,CAAC,GAAG,aAAa,IAAI,OAAO,IAAI,CAAC,KAAK,aAAa,IAAI,OAAO,KAAK,CAAC,EAAE;AAAA,QAC5E,WAAW,EAAE,OAAO,aAAa,IAAI,OAAO,IAAI,GAAG,UAAU,gBAAgB;AAAA,QAC7E,gBAAgB,EAAE,QAAQ,IAAI,QAAQ,cAAc,IAAI,aAAa;AAAA,MACvE;AAAA,MACA,cAAc,OAAO,SAAS;AAAA,QAC5B,OAAO,aAAa,IAAI,OAAO,IAAI;AAAA,QACnC,UAAU;AAAA,QACV,MAAM;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACF;AAEO,MAAM,SAAS;AACtB,IAAO,iBAAQ;",
4
+ "sourcesContent": ["import type { SearchModuleConfig } from '@open-mercato/shared/modules/search'\nimport { CHECKOUT_ENTITY_IDS } from './lib/constants'\n\nfunction asSearchText(value: unknown): string {\n return typeof value === 'string' ? value : ''\n}\n\nexport const searchConfig: SearchModuleConfig = {\n entities: [\n {\n entityId: CHECKOUT_ENTITY_IDS.link,\n enabled: true,\n priority: 10,\n fieldPolicy: {\n searchable: ['name', 'title', 'slug'],\n excluded: ['passwordHash', 'gatewaySettings', 'customerFieldsSchema'],\n },\n buildSource: async (ctx) => ({\n text: [`${asSearchText(ctx.record.name)}: ${asSearchText(ctx.record.title)} (${asSearchText(ctx.record.slug)})`],\n presenter: { title: asSearchText(ctx.record.name), subtitle: asSearchText(ctx.record.slug) },\n checksumSource: { record: ctx.record, customFields: ctx.customFields },\n }),\n formatResult: async (ctx) => ({\n title: asSearchText(ctx.record.name),\n subtitle: `/pay/${asSearchText(ctx.record.slug)}`,\n icon: 'lucide:link',\n }),\n },\n {\n entityId: CHECKOUT_ENTITY_IDS.template,\n enabled: true,\n priority: 8,\n fieldPolicy: {\n searchable: ['name', 'title'],\n excluded: ['passwordHash', 'gatewaySettings', 'customerFieldsSchema'],\n },\n buildSource: async (ctx) => ({\n text: [`${asSearchText(ctx.record.name)}: ${asSearchText(ctx.record.title)}`],\n presenter: { title: asSearchText(ctx.record.name), subtitle: 'Link Template' },\n checksumSource: { record: ctx.record, customFields: ctx.customFields },\n }),\n formatResult: async (ctx) => ({\n title: asSearchText(ctx.record.name),\n subtitle: 'Link Template',\n icon: 'lucide:file-text',\n }),\n },\n {\n entityId: CHECKOUT_ENTITY_IDS.transaction,\n enabled: true,\n priority: 6,\n fieldPolicy: {\n searchable: ['email', 'firstName', 'lastName'],\n },\n buildSource: async (ctx) => ({\n text: [\n asSearchText(ctx.record.email),\n asSearchText(ctx.record.firstName),\n asSearchText(ctx.record.lastName),\n ],\n fields: {\n email: ctx.record.email,\n first_name: ctx.record.firstName,\n last_name: ctx.record.lastName,\n },\n presenter: {\n title: asSearchText(ctx.record.email),\n subtitle: `${asSearchText(ctx.record.firstName)} ${asSearchText(ctx.record.lastName)}`.trim(),\n },\n checksumSource: { record: ctx.record },\n }),\n formatResult: async (ctx) => ({\n title: asSearchText(ctx.record.email),\n subtitle: `${asSearchText(ctx.record.firstName)} ${asSearchText(ctx.record.lastName)}`.trim(),\n icon: 'lucide:receipt',\n }),\n },\n ],\n}\n\nexport const config = searchConfig\nexport default searchConfig\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,12 +1,40 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { ensureCheckoutFieldsetsAndDefinitions } from "./seed/customFields.js";
2
3
  import { seedCheckoutExamples } from "./seed/examples.js";
4
+ import { CHECKOUT_EXPIRY_QUEUE } from "./workers/transaction-expiry.worker.js";
3
5
  import { DEFAULT_CHECKOUT_CUSTOMER_FIELDS } from "./lib/defaults.js";
6
+ function stableUuidFromString(input) {
7
+ const bytes = createHash("sha256").update(input).digest().subarray(0, 16);
8
+ bytes[6] = bytes[6] & 15 | 80;
9
+ bytes[8] = bytes[8] & 63 | 128;
10
+ const hex = Buffer.from(bytes).toString("hex");
11
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
12
+ }
4
13
  const setup = {
5
14
  async seedDefaults(ctx) {
6
15
  await ensureCheckoutFieldsetsAndDefinitions(ctx.em, {
7
16
  tenantId: ctx.tenantId,
8
17
  organizationId: ctx.organizationId
9
18
  });
19
+ const cradle = ctx.container;
20
+ if (typeof cradle.hasRegistration !== "function" || !cradle.hasRegistration("schedulerService")) return;
21
+ const schedulerService = ctx.container.resolve("schedulerService");
22
+ await schedulerService.register({
23
+ id: stableUuidFromString(`checkout:transaction-expiry:${ctx.tenantId}:${ctx.organizationId}`),
24
+ name: "Checkout transaction expiry",
25
+ description: "Marks stale checkout transactions as expired and runs usage limit updates.",
26
+ scopeType: "organization",
27
+ organizationId: ctx.organizationId,
28
+ tenantId: ctx.tenantId,
29
+ scheduleType: "interval",
30
+ scheduleValue: "10m",
31
+ timezone: "UTC",
32
+ targetType: "queue",
33
+ targetQueue: CHECKOUT_EXPIRY_QUEUE,
34
+ sourceType: "module",
35
+ sourceModule: "checkout",
36
+ isEnabled: true
37
+ });
10
38
  },
11
39
  defaultRoleFeatures: {
12
40
  superadmin: ["checkout.*"],
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/checkout/setup.ts"],
4
- "sourcesContent": ["import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\nimport { ensureCheckoutFieldsetsAndDefinitions } from './seed/customFields'\nimport { seedCheckoutExamples } from './seed/examples'\nexport { DEFAULT_CHECKOUT_CUSTOMER_FIELDS } from './lib/defaults'\n\nexport const setup: ModuleSetupConfig = {\n async seedDefaults(ctx) {\n await ensureCheckoutFieldsetsAndDefinitions(ctx.em, {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n })\n },\n\n defaultRoleFeatures: {\n superadmin: ['checkout.*'],\n admin: ['checkout.view', 'checkout.create', 'checkout.edit', 'checkout.delete', 'checkout.viewPii', 'checkout.export'],\n employee: ['checkout.view'],\n },\n\n async seedExamples(ctx) {\n await seedCheckoutExamples(ctx)\n },\n}\n\nexport default setup\n"],
5
- "mappings": "AACA,SAAS,6CAA6C;AACtD,SAAS,4BAA4B;AACrC,SAAS,wCAAwC;AAE1C,MAAM,QAA2B;AAAA,EACtC,MAAM,aAAa,KAAK;AACtB,UAAM,sCAAsC,IAAI,IAAI;AAAA,MAClD,UAAU,IAAI;AAAA,MACd,gBAAgB,IAAI;AAAA,IACtB,CAAC;AAAA,EACH;AAAA,EAEA,qBAAqB;AAAA,IACnB,YAAY,CAAC,YAAY;AAAA,IACzB,OAAO,CAAC,iBAAiB,mBAAmB,iBAAiB,mBAAmB,oBAAoB,iBAAiB;AAAA,IACrH,UAAU,CAAC,eAAe;AAAA,EAC5B;AAAA,EAEA,MAAM,aAAa,KAAK;AACtB,UAAM,qBAAqB,GAAG;AAAA,EAChC;AACF;AAEA,IAAO,gBAAQ;",
4
+ "sourcesContent": ["import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\nimport { createHash } from 'node:crypto'\nimport { ensureCheckoutFieldsetsAndDefinitions } from './seed/customFields'\nimport { seedCheckoutExamples } from './seed/examples'\nimport { CHECKOUT_EXPIRY_QUEUE } from './workers/transaction-expiry.worker'\nexport { DEFAULT_CHECKOUT_CUSTOMER_FIELDS } from './lib/defaults'\n\nfunction stableUuidFromString(input: string): string {\n const bytes = createHash('sha256').update(input).digest().subarray(0, 16)\n bytes[6] = (bytes[6] & 0x0f) | 0x50\n bytes[8] = (bytes[8] & 0x3f) | 0x80\n const hex = Buffer.from(bytes).toString('hex')\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`\n}\n\ntype SchedulerServiceLike = {\n register: (registration: {\n id: string\n name: string\n description?: string\n scopeType: 'organization'\n organizationId: string\n tenantId: string\n scheduleType: 'cron' | 'interval'\n scheduleValue: string\n timezone?: string\n targetType: 'queue'\n targetQueue: string\n targetPayload?: Record<string, unknown>\n sourceType: 'module'\n sourceModule: string\n isEnabled?: boolean\n }) => Promise<void>\n}\n\nexport const setup: ModuleSetupConfig = {\n async seedDefaults(ctx) {\n await ensureCheckoutFieldsetsAndDefinitions(ctx.em, {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n })\n\n const cradle = ctx.container as { hasRegistration?: (name: string) => boolean }\n if (typeof cradle.hasRegistration !== 'function' || !cradle.hasRegistration('schedulerService')) return\n\n const schedulerService = ctx.container.resolve('schedulerService') as SchedulerServiceLike\n await schedulerService.register({\n id: stableUuidFromString(`checkout:transaction-expiry:${ctx.tenantId}:${ctx.organizationId}`),\n name: 'Checkout transaction expiry',\n description: 'Marks stale checkout transactions as expired and runs usage limit updates.',\n scopeType: 'organization',\n organizationId: ctx.organizationId,\n tenantId: ctx.tenantId,\n scheduleType: 'interval',\n scheduleValue: '10m',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: CHECKOUT_EXPIRY_QUEUE,\n sourceType: 'module',\n sourceModule: 'checkout',\n isEnabled: true,\n })\n },\n\n defaultRoleFeatures: {\n superadmin: ['checkout.*'],\n admin: ['checkout.view', 'checkout.create', 'checkout.edit', 'checkout.delete', 'checkout.viewPii', 'checkout.export'],\n employee: ['checkout.view'],\n },\n\n async seedExamples(ctx) {\n await seedCheckoutExamples(ctx)\n },\n}\n\nexport default setup\n"],
5
+ "mappings": "AACA,SAAS,kBAAkB;AAC3B,SAAS,6CAA6C;AACtD,SAAS,4BAA4B;AACrC,SAAS,6BAA6B;AACtC,SAAS,wCAAwC;AAEjD,SAAS,qBAAqB,OAAuB;AACnD,QAAM,QAAQ,WAAW,QAAQ,EAAE,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,GAAG,EAAE;AACxE,QAAM,CAAC,IAAK,MAAM,CAAC,IAAI,KAAQ;AAC/B,QAAM,CAAC,IAAK,MAAM,CAAC,IAAI,KAAQ;AAC/B,QAAM,MAAM,OAAO,KAAK,KAAK,EAAE,SAAS,KAAK;AAC7C,SAAO,GAAG,IAAI,MAAM,GAAG,CAAC,CAAC,IAAI,IAAI,MAAM,GAAG,EAAE,CAAC,IAAI,IAAI,MAAM,IAAI,EAAE,CAAC,IAAI,IAAI,MAAM,IAAI,EAAE,CAAC,IAAI,IAAI,MAAM,IAAI,EAAE,CAAC;AAC9G;AAsBO,MAAM,QAA2B;AAAA,EACtC,MAAM,aAAa,KAAK;AACtB,UAAM,sCAAsC,IAAI,IAAI;AAAA,MAClD,UAAU,IAAI;AAAA,MACd,gBAAgB,IAAI;AAAA,IACtB,CAAC;AAED,UAAM,SAAS,IAAI;AACnB,QAAI,OAAO,OAAO,oBAAoB,cAAc,CAAC,OAAO,gBAAgB,kBAAkB,EAAG;AAEjG,UAAM,mBAAmB,IAAI,UAAU,QAAQ,kBAAkB;AACjE,UAAM,iBAAiB,SAAS;AAAA,MAC9B,IAAI,qBAAqB,+BAA+B,IAAI,QAAQ,IAAI,IAAI,cAAc,EAAE;AAAA,MAC5F,MAAM;AAAA,MACN,aAAa;AAAA,MACb,WAAW;AAAA,MACX,gBAAgB,IAAI;AAAA,MACpB,UAAU,IAAI;AAAA,MACd,cAAc;AAAA,MACd,eAAe;AAAA,MACf,UAAU;AAAA,MACV,YAAY;AAAA,MACZ,aAAa;AAAA,MACb,YAAY;AAAA,MACZ,cAAc;AAAA,MACd,WAAW;AAAA,IACb,CAAC;AAAA,EACH;AAAA,EAEA,qBAAqB;AAAA,IACnB,YAAY,CAAC,YAAY;AAAA,IACzB,OAAO,CAAC,iBAAiB,mBAAmB,iBAAiB,mBAAmB,oBAAoB,iBAAiB;AAAA,IACrH,UAAU,CAAC,eAAe;AAAA,EAC5B;AAAA,EAEA,MAAM,aAAa,KAAK;AACtB,UAAM,qBAAqB,GAAG;AAAA,EAChC;AACF;AAEA,IAAO,gBAAQ;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,32 @@
1
+ const metadata = {
2
+ event: "checkout.transaction.created",
3
+ persistent: false,
4
+ id: "checkout:query-index-reindex-transaction-created"
5
+ };
6
+ async function handle(payload, ctx) {
7
+ const recordId = typeof payload.transactionId === "string" ? payload.transactionId : null;
8
+ const tenantId = typeof payload.tenantId === "string" ? payload.tenantId : null;
9
+ if (!recordId || !tenantId) return;
10
+ let eventBus = null;
11
+ try {
12
+ eventBus = ctx.resolve("eventBus");
13
+ } catch {
14
+ return;
15
+ }
16
+ await eventBus.emitEvent("query_index.upsert_one", {
17
+ entityType: "checkout:checkout_transaction",
18
+ recordId,
19
+ organizationId: payload.organizationId ?? null,
20
+ tenantId,
21
+ crudAction: "created",
22
+ coverageBaseDelta: 1
23
+ }, {
24
+ tenantId,
25
+ organizationId: payload.organizationId ?? null
26
+ }).catch(() => void 0);
27
+ }
28
+ export {
29
+ handle as default,
30
+ metadata
31
+ };
32
+ //# sourceMappingURL=search-reindex-transaction-created.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/checkout/subscribers/search-reindex-transaction-created.ts"],
4
+ "sourcesContent": ["export const metadata = {\n event: 'checkout.transaction.created',\n persistent: false,\n id: 'checkout:query-index-reindex-transaction-created',\n}\n\ntype Payload = {\n transactionId?: string\n tenantId?: string | null\n organizationId?: string | null\n}\n\ntype EventBus = { emitEvent: (name: string, body: unknown, options?: unknown) => Promise<void> }\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\nexport default async function handle(payload: Payload, ctx: HandlerContext): Promise<void> {\n const recordId = typeof payload.transactionId === 'string' ? payload.transactionId : null\n const tenantId = typeof payload.tenantId === 'string' ? payload.tenantId : null\n if (!recordId || !tenantId) return\n\n let eventBus: EventBus | null = null\n try {\n eventBus = ctx.resolve<EventBus>('eventBus')\n } catch {\n return\n }\n\n await eventBus.emitEvent('query_index.upsert_one', {\n entityType: 'checkout:checkout_transaction',\n recordId,\n organizationId: payload.organizationId ?? null,\n tenantId,\n crudAction: 'created',\n coverageBaseDelta: 1,\n }, {\n tenantId,\n organizationId: payload.organizationId ?? null,\n }).catch(() => undefined)\n}\n"],
5
+ "mappings": "AAAO,MAAM,WAAW;AAAA,EACtB,OAAO;AAAA,EACP,YAAY;AAAA,EACZ,IAAI;AACN;AAWA,eAAO,OAA8B,SAAkB,KAAoC;AACzF,QAAM,WAAW,OAAO,QAAQ,kBAAkB,WAAW,QAAQ,gBAAgB;AACrF,QAAM,WAAW,OAAO,QAAQ,aAAa,WAAW,QAAQ,WAAW;AAC3E,MAAI,CAAC,YAAY,CAAC,SAAU;AAE5B,MAAI,WAA4B;AAChC,MAAI;AACF,eAAW,IAAI,QAAkB,UAAU;AAAA,EAC7C,QAAQ;AACN;AAAA,EACF;AAEA,QAAM,SAAS,UAAU,0BAA0B;AAAA,IACjD,YAAY;AAAA,IACZ;AAAA,IACA,gBAAgB,QAAQ,kBAAkB;AAAA,IAC1C;AAAA,IACA,YAAY;AAAA,IACZ,mBAAmB;AAAA,EACrB,GAAG;AAAA,IACD;AAAA,IACA,gBAAgB,QAAQ,kBAAkB;AAAA,EAC5C,CAAC,EAAE,MAAM,MAAM,MAAS;AAC1B;",
6
+ "names": []
7
+ }
@@ -14,14 +14,21 @@ async function handle(job, ctx) {
14
14
  const commandBus = ctx.resolve("commandBus");
15
15
  const batchSize = job.payload?.batchSize ?? 100;
16
16
  const cutoff = new Date(Date.now() - EXPIRY_TIMEOUT_MS);
17
+ if (!job.payload?.tenantId || !job.payload?.organizationId) {
18
+ throw new Error("[internal] tenantId and organizationId are required in CheckoutExpiryJob");
19
+ }
20
+ const { tenantId, organizationId } = job.payload;
17
21
  const staleTransactions = await findWithDecryption(
18
22
  em,
19
23
  CheckoutTransaction,
20
24
  {
21
25
  status: "processing",
22
- createdAt: { $lt: cutoff }
26
+ createdAt: { $lt: cutoff },
27
+ tenantId,
28
+ organizationId
23
29
  },
24
- { limit: batchSize, orderBy: { createdAt: "ASC" } }
30
+ { limit: batchSize, orderBy: { createdAt: "ASC" } },
31
+ { tenantId, organizationId }
25
32
  );
26
33
  for (const transaction of staleTransactions) {
27
34
  try {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/checkout/workers/transaction-expiry.worker.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CheckoutTransaction } from '../data/entities'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('checkout').child({ component: 'transaction-expiry' })\n\nexport const CHECKOUT_EXPIRY_QUEUE = 'checkout-transaction-expiry'\n\nconst EXPIRY_TIMEOUT_MS = 2 * 60 * 60 * 1000 // 2 hours\n\nexport type CheckoutExpiryJob = {\n batchSize?: number\n}\n\nexport const metadata: WorkerMeta = {\n queue: CHECKOUT_EXPIRY_QUEUE,\n id: 'checkout:transaction-expiry',\n concurrency: 1,\n}\n\ntype HandlerContext = JobContext & {\n resolve: <T = unknown>(name: string) => T\n}\n\nexport default async function handle(job: QueuedJob<CheckoutExpiryJob>, ctx: HandlerContext): Promise<void> {\n const em = (ctx.resolve('em') as EntityManager).fork()\n const commandBus = ctx.resolve('commandBus') as CommandBus\n const batchSize = job.payload?.batchSize ?? 100\n const cutoff = new Date(Date.now() - EXPIRY_TIMEOUT_MS)\n\n const staleTransactions = await findWithDecryption(\n em,\n CheckoutTransaction,\n {\n status: 'processing',\n createdAt: { $lt: cutoff },\n },\n { limit: batchSize, orderBy: { createdAt: 'ASC' } },\n )\n\n for (const transaction of staleTransactions) {\n try {\n const commandCtx: CommandRuntimeContext = {\n container: { resolve: ctx.resolve } as unknown as CommandRuntimeContext['container'],\n auth: null,\n organizationScope: null,\n selectedOrganizationId: transaction.organizationId,\n organizationIds: [transaction.organizationId],\n }\n await commandBus.execute('checkout.transaction.updateStatus', {\n input: {\n id: transaction.id,\n status: 'expired',\n paymentStatus: 'expired',\n organizationId: transaction.organizationId,\n tenantId: transaction.tenantId,\n },\n ctx: commandCtx,\n })\n } catch (error) {\n logger.error('Failed to expire transaction', { transactionId: transaction.id, err: error })\n }\n }\n}\n"],
5
- "mappings": "AAIA,SAAS,0BAA0B;AACnC,SAAS,2BAA2B;AACpC,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,UAAU,EAAE,MAAM,EAAE,WAAW,qBAAqB,CAAC;AAE1E,MAAM,wBAAwB;AAErC,MAAM,oBAAoB,IAAI,KAAK,KAAK;AAMjC,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,IAAI;AAAA,EACJ,aAAa;AACf;AAMA,eAAO,OAA8B,KAAmC,KAAoC;AAC1G,QAAM,KAAM,IAAI,QAAQ,IAAI,EAAoB,KAAK;AACrD,QAAM,aAAa,IAAI,QAAQ,YAAY;AAC3C,QAAM,YAAY,IAAI,SAAS,aAAa;AAC5C,QAAM,SAAS,IAAI,KAAK,KAAK,IAAI,IAAI,iBAAiB;AAEtD,QAAM,oBAAoB,MAAM;AAAA,IAC9B;AAAA,IACA;AAAA,IACA;AAAA,MACE,QAAQ;AAAA,MACR,WAAW,EAAE,KAAK,OAAO;AAAA,IAC3B;AAAA,IACA,EAAE,OAAO,WAAW,SAAS,EAAE,WAAW,MAAM,EAAE;AAAA,EACpD;AAEA,aAAW,eAAe,mBAAmB;AAC3C,QAAI;AACF,YAAM,aAAoC;AAAA,QACxC,WAAW,EAAE,SAAS,IAAI,QAAQ;AAAA,QAClC,MAAM;AAAA,QACN,mBAAmB;AAAA,QACnB,wBAAwB,YAAY;AAAA,QACpC,iBAAiB,CAAC,YAAY,cAAc;AAAA,MAC9C;AACA,YAAM,WAAW,QAAQ,qCAAqC;AAAA,QAC5D,OAAO;AAAA,UACL,IAAI,YAAY;AAAA,UAChB,QAAQ;AAAA,UACR,eAAe;AAAA,UACf,gBAAgB,YAAY;AAAA,UAC5B,UAAU,YAAY;AAAA,QACxB;AAAA,QACA,KAAK;AAAA,MACP,CAAC;AAAA,IACH,SAAS,OAAO;AACd,aAAO,MAAM,gCAAgC,EAAE,eAAe,YAAY,IAAI,KAAK,MAAM,CAAC;AAAA,IAC5F;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CheckoutTransaction } from '../data/entities'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('checkout').child({ component: 'transaction-expiry' })\n\nexport const CHECKOUT_EXPIRY_QUEUE = 'checkout-transaction-expiry'\n\nconst EXPIRY_TIMEOUT_MS = 2 * 60 * 60 * 1000 // 2 hours\n\nexport type CheckoutExpiryJob = {\n batchSize?: number\n tenantId: string\n organizationId: string\n}\n\nexport const metadata: WorkerMeta = {\n queue: CHECKOUT_EXPIRY_QUEUE,\n id: 'checkout:transaction-expiry',\n concurrency: 1,\n}\n\ntype HandlerContext = JobContext & {\n resolve: <T = unknown>(name: string) => T\n}\n\nexport default async function handle(job: QueuedJob<CheckoutExpiryJob>, ctx: HandlerContext): Promise<void> {\n const em = (ctx.resolve('em') as EntityManager).fork()\n const commandBus = ctx.resolve('commandBus') as CommandBus\n const batchSize = job.payload?.batchSize ?? 100\n const cutoff = new Date(Date.now() - EXPIRY_TIMEOUT_MS)\n\n if (!job.payload?.tenantId || !job.payload?.organizationId) {\n throw new Error('[internal] tenantId and organizationId are required in CheckoutExpiryJob')\n }\n\n const { tenantId, organizationId } = job.payload\n\n const staleTransactions = await findWithDecryption(\n em,\n CheckoutTransaction,\n {\n status: 'processing',\n createdAt: { $lt: cutoff },\n tenantId,\n organizationId,\n },\n { limit: batchSize, orderBy: { createdAt: 'ASC' } },\n { tenantId, organizationId },\n )\n\n for (const transaction of staleTransactions) {\n try {\n const commandCtx: CommandRuntimeContext = {\n container: { resolve: ctx.resolve } as unknown as CommandRuntimeContext['container'],\n auth: null,\n organizationScope: null,\n selectedOrganizationId: transaction.organizationId,\n organizationIds: [transaction.organizationId],\n }\n await commandBus.execute('checkout.transaction.updateStatus', {\n input: {\n id: transaction.id,\n status: 'expired',\n paymentStatus: 'expired',\n organizationId: transaction.organizationId,\n tenantId: transaction.tenantId,\n },\n ctx: commandCtx,\n })\n } catch (error) {\n logger.error('Failed to expire transaction', { transactionId: transaction.id, err: error })\n }\n }\n}\n"],
5
+ "mappings": "AAIA,SAAS,0BAA0B;AACnC,SAAS,2BAA2B;AACpC,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,UAAU,EAAE,MAAM,EAAE,WAAW,qBAAqB,CAAC;AAE1E,MAAM,wBAAwB;AAErC,MAAM,oBAAoB,IAAI,KAAK,KAAK;AAQjC,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,IAAI;AAAA,EACJ,aAAa;AACf;AAMA,eAAO,OAA8B,KAAmC,KAAoC;AAC1G,QAAM,KAAM,IAAI,QAAQ,IAAI,EAAoB,KAAK;AACrD,QAAM,aAAa,IAAI,QAAQ,YAAY;AAC3C,QAAM,YAAY,IAAI,SAAS,aAAa;AAC5C,QAAM,SAAS,IAAI,KAAK,KAAK,IAAI,IAAI,iBAAiB;AAEtD,MAAI,CAAC,IAAI,SAAS,YAAY,CAAC,IAAI,SAAS,gBAAgB;AAC1D,UAAM,IAAI,MAAM,0EAA0E;AAAA,EAC5F;AAEA,QAAM,EAAE,UAAU,eAAe,IAAI,IAAI;AAEzC,QAAM,oBAAoB,MAAM;AAAA,IAC9B;AAAA,IACA;AAAA,IACA;AAAA,MACE,QAAQ;AAAA,MACR,WAAW,EAAE,KAAK,OAAO;AAAA,MACzB;AAAA,MACA;AAAA,IACF;AAAA,IACA,EAAE,OAAO,WAAW,SAAS,EAAE,WAAW,MAAM,EAAE;AAAA,IAClD,EAAE,UAAU,eAAe;AAAA,EAC7B;AAEA,aAAW,eAAe,mBAAmB;AAC3C,QAAI;AACF,YAAM,aAAoC;AAAA,QACxC,WAAW,EAAE,SAAS,IAAI,QAAQ;AAAA,QAClC,MAAM;AAAA,QACN,mBAAmB;AAAA,QACnB,wBAAwB,YAAY;AAAA,QACpC,iBAAiB,CAAC,YAAY,cAAc;AAAA,MAC9C;AACA,YAAM,WAAW,QAAQ,qCAAqC;AAAA,QAC5D,OAAO;AAAA,UACL,IAAI,YAAY;AAAA,UAChB,QAAQ;AAAA,UACR,eAAe;AAAA,UACf,gBAAgB,YAAY;AAAA,UAC5B,UAAU,YAAY;AAAA,QACxB;AAAA,QACA,KAAK;AAAA,MACP,CAAC;AAAA,IACH,SAAS,OAAO;AACd,aAAO,MAAM,gCAAgC,EAAE,eAAe,YAAY,IAAI,KAAK,MAAM,CAAC;AAAA,IAC5F;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/checkout",
3
- "version": "0.6.7-develop.6696.1.4236709f7e",
3
+ "version": "0.6.7-develop.6725.1.07a748cfc2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -62,18 +62,18 @@
62
62
  }
63
63
  },
64
64
  "dependencies": {
65
- "@open-mercato/core": "0.6.7-develop.6696.1.4236709f7e",
66
- "@open-mercato/ui": "0.6.7-develop.6696.1.4236709f7e",
65
+ "@open-mercato/core": "0.6.7-develop.6725.1.07a748cfc2",
66
+ "@open-mercato/ui": "0.6.7-develop.6725.1.07a748cfc2",
67
67
  "bcryptjs": "^3.0.3"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@mikro-orm/postgresql": "^7.0.14",
71
- "@open-mercato/shared": "0.6.7-develop.6696.1.4236709f7e",
71
+ "@open-mercato/shared": "0.6.7-develop.6725.1.07a748cfc2",
72
72
  "react": "^19.0.0",
73
73
  "react-dom": "^19.0.0"
74
74
  },
75
75
  "devDependencies": {
76
- "@open-mercato/shared": "0.6.7-develop.6696.1.4236709f7e",
76
+ "@open-mercato/shared": "0.6.7-develop.6725.1.07a748cfc2",
77
77
  "@types/jest": "^30.0.0",
78
78
  "@types/react": "^19.2.17",
79
79
  "@types/react-dom": "^19.2.3",
@@ -0,0 +1,41 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { getAuthToken } from '@open-mercato/core/modules/core/__integration__/helpers/api'
3
+ import {
4
+ createCustomerData,
5
+ createFixedTemplateInput,
6
+ createLinkFixture,
7
+ deleteCheckoutEntityIfExists,
8
+ listCheckoutTransactions,
9
+ submitPayLink,
10
+ } from './helpers/fixtures'
11
+
12
+ test.describe('TC-CHKT-042: Transaction search over encrypted customer data', () => {
13
+ test('finds a newly created transaction through its token index', async ({ request }) => {
14
+ const email = `token-search-${Date.now()}@example.test`
15
+ let token: string | null = null
16
+ let linkId: string | null = null
17
+
18
+ try {
19
+ token = await getAuthToken(request)
20
+ const link = await createLinkFixture(request, token, createFixedTemplateInput({ status: 'active' }))
21
+ linkId = link.id
22
+ const submitted = await submitPayLink(request, link.slug, {
23
+ customerData: createCustomerData({ email }),
24
+ acceptedLegalConsents: {},
25
+ amount: 49.99,
26
+ })
27
+ expect(submitted.ok()).toBeTruthy()
28
+
29
+ await expect.poll(async () => {
30
+ const result = await listCheckoutTransactions(
31
+ request,
32
+ token!,
33
+ `search=${encodeURIComponent(email)}&page=1&pageSize=10`,
34
+ )
35
+ return result.items.some((item) => item.email === email)
36
+ }, { timeout: 15_000 }).toBeTruthy()
37
+ } finally {
38
+ await deleteCheckoutEntityIfExists(request, token, 'links', linkId)
39
+ }
40
+ })
41
+ })
@@ -0,0 +1,21 @@
1
+ import searchConfig from '../search'
2
+ import { CHECKOUT_ENTITY_IDS } from '../lib/constants'
3
+
4
+ describe('checkout search configuration', () => {
5
+ it('indexes the encrypted transaction fields used by the list API', async () => {
6
+ const transaction = searchConfig.entities.find((entry) => entry.entityId === CHECKOUT_ENTITY_IDS.transaction)
7
+ expect(transaction?.fieldPolicy?.searchable).toEqual(['email', 'firstName', 'lastName'])
8
+
9
+ const source = await transaction?.buildSource?.({
10
+ record: { email: 'buyer@example.test', firstName: 'Ada', lastName: 'Lovelace' },
11
+ customFields: {},
12
+ tenantId: 'tenant-1',
13
+ organizationId: 'org-1',
14
+ })
15
+ expect(source?.fields).toMatchObject({
16
+ email: 'buyer@example.test',
17
+ first_name: 'Ada',
18
+ last_name: 'Lovelace',
19
+ })
20
+ })
21
+ })
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it, jest } from '@jest/globals'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import type { AwilixContainer } from 'awilix'
4
+ import type { InitSetupContext } from '@open-mercato/shared/modules/setup'
5
+ import { CHECKOUT_EXPIRY_QUEUE } from '../workers/transaction-expiry.worker'
6
+
7
+ const ensureCheckoutFieldsetsAndDefinitions = jest.fn(async (..._args: unknown[]) => undefined)
8
+
9
+ jest.mock('../seed/customFields', () => ({
10
+ ensureCheckoutFieldsetsAndDefinitions: (...args: unknown[]) => ensureCheckoutFieldsetsAndDefinitions(...args),
11
+ }))
12
+
13
+ type ScheduleRegistration = {
14
+ id: string
15
+ organizationId: string
16
+ tenantId: string
17
+ scopeType: string
18
+ targetQueue: string
19
+ }
20
+
21
+ function createSetupContext(options: {
22
+ tenantId?: string
23
+ organizationId?: string
24
+ hasScheduler?: boolean
25
+ register?: (registration: ScheduleRegistration) => Promise<void>
26
+ }) {
27
+ const register = jest.fn(options.register ?? (async () => undefined))
28
+ const resolve = jest.fn((name: string) => {
29
+ if (name === 'schedulerService') return { register }
30
+ throw new Error(`[internal] Missing dependency: ${name}`)
31
+ })
32
+ const container = {
33
+ hasRegistration: jest.fn(() => options.hasScheduler ?? true),
34
+ resolve,
35
+ }
36
+ const context: InitSetupContext = {
37
+ tenantId: options.tenantId ?? 'tenant-1',
38
+ organizationId: options.organizationId ?? 'organization-1',
39
+ em: {} as EntityManager,
40
+ container: container as unknown as AwilixContainer,
41
+ }
42
+ return { context, register, resolve }
43
+ }
44
+
45
+ async function seedDefaults(context: InitSetupContext): Promise<void> {
46
+ const { setup } = await import('../setup')
47
+ if (!setup.seedDefaults) throw new Error('[internal] Checkout seedDefaults hook is missing')
48
+ await setup.seedDefaults(context)
49
+ }
50
+
51
+ describe('checkout setup schedules', () => {
52
+ it('registers one organization-scoped transaction-expiry schedule', async () => {
53
+ const { context, register } = createSetupContext({})
54
+
55
+ await seedDefaults(context)
56
+
57
+ expect(register).toHaveBeenCalledWith(expect.objectContaining({
58
+ organizationId: 'organization-1',
59
+ tenantId: 'tenant-1',
60
+ scopeType: 'organization',
61
+ targetQueue: CHECKOUT_EXPIRY_QUEUE,
62
+ }))
63
+ })
64
+
65
+ it('uses stable IDs that remain distinct across organizations in one tenant', async () => {
66
+ const first = createSetupContext({ organizationId: 'organization-1' })
67
+ const second = createSetupContext({ organizationId: 'organization-2' })
68
+ const repeated = createSetupContext({ organizationId: 'organization-1' })
69
+
70
+ await seedDefaults(first.context)
71
+ await seedDefaults(second.context)
72
+ await seedDefaults(repeated.context)
73
+
74
+ const firstId = first.register.mock.calls[0][0].id
75
+ const secondId = second.register.mock.calls[0][0].id
76
+ const repeatedId = repeated.register.mock.calls[0][0].id
77
+ expect(firstId).not.toBe(secondId)
78
+ expect(repeatedId).toBe(firstId)
79
+ })
80
+
81
+ it('skips schedule registration when the optional scheduler module is absent', async () => {
82
+ const { context, register, resolve } = createSetupContext({ hasScheduler: false })
83
+
84
+ await seedDefaults(context)
85
+
86
+ expect(register).not.toHaveBeenCalled()
87
+ expect(resolve).not.toHaveBeenCalled()
88
+ })
89
+
90
+ it('propagates scheduler registration failures', async () => {
91
+ const { context } = createSetupContext({
92
+ register: async () => {
93
+ throw new Error('[internal] scheduler unavailable')
94
+ },
95
+ })
96
+
97
+ await expect(seedDefaults(context)).rejects.toThrow('[internal] scheduler unavailable')
98
+ })
99
+ })
@@ -0,0 +1,113 @@
1
+ /** @jest-environment node */
2
+
3
+ import { GET } from '../route'
4
+ import { CheckoutTransaction } from '../../../data/entities'
5
+
6
+ const mockFindAndCountWithDecryption = jest.fn()
7
+ const mockFindWithDecryption = jest.fn()
8
+
9
+ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
10
+ findAndCountWithDecryption: (...args: unknown[]) => mockFindAndCountWithDecryption(...args),
11
+ findWithDecryption: (...args: unknown[]) => mockFindWithDecryption(...args),
12
+ }))
13
+
14
+ const mockFindEntityIdsBySearchTokens = jest.fn(async () => ({ matched: false, reason: 'no-tokens' as const }))
15
+
16
+ jest.mock('@open-mercato/shared/lib/search/tokenLookup', () => ({
17
+ findEntityIdsBySearchTokens: (...args: unknown[]) => mockFindEntityIdsBySearchTokens(...(args as [])),
18
+ }))
19
+
20
+ const mockEm = { getKysely: jest.fn(() => ({})) }
21
+ const auth = { tenantId: 'tenant-1', orgId: 'org-1', sub: 'user-1' }
22
+
23
+ jest.mock('../../helpers', () => ({
24
+ requireAdminContext: jest.fn(async () => ({ auth, container: {}, em: mockEm, commandBus: {} })),
25
+ userHasCheckoutFeature: jest.fn(async () => true),
26
+ handleCheckoutRouteError: (error: unknown) => {
27
+ throw error
28
+ },
29
+ }))
30
+
31
+ function makeRequest(params?: Record<string, string>) {
32
+ const url = new URL('http://localhost/api/checkout/transactions')
33
+ for (const [key, value] of Object.entries(params ?? {})) url.searchParams.set(key, value)
34
+ return new Request(url.toString(), { method: 'GET' })
35
+ }
36
+
37
+ function capturedWhere(): Record<string, unknown> {
38
+ return mockFindAndCountWithDecryption.mock.calls[0][2] as Record<string, unknown>
39
+ }
40
+
41
+ describe('GET /api/checkout/transactions', () => {
42
+ beforeEach(() => {
43
+ jest.clearAllMocks()
44
+ mockFindAndCountWithDecryption.mockResolvedValue([[], 0])
45
+ mockFindWithDecryption.mockResolvedValue([])
46
+ mockFindEntityIdsBySearchTokens.mockResolvedValue({ matched: false, reason: 'no-tokens' } as never)
47
+ })
48
+
49
+ it('applies no search predicate when no term is supplied', async () => {
50
+ await GET(makeRequest())
51
+
52
+ expect(mockFindAndCountWithDecryption).toHaveBeenCalledWith(
53
+ mockEm,
54
+ CheckoutTransaction,
55
+ { organizationId: 'org-1', tenantId: 'tenant-1' },
56
+ expect.any(Object),
57
+ expect.any(Object),
58
+ )
59
+ expect(mockFindEntityIdsBySearchTokens).not.toHaveBeenCalled()
60
+ })
61
+
62
+ it('keeps the escaped ILIKE predicates for the PII columns', async () => {
63
+ await GET(makeRequest({ search: '100%_off' }))
64
+
65
+ expect(capturedWhere().$or).toEqual([
66
+ { email: { $ilike: '%100\\%\\_off%' } },
67
+ { firstName: { $ilike: '%100\\%\\_off%' } },
68
+ { lastName: { $ilike: '%100\\%\\_off%' } },
69
+ ])
70
+ })
71
+
72
+ it('unions the encrypted-column token matches into the search predicate', async () => {
73
+ mockFindEntityIdsBySearchTokens.mockResolvedValueOnce({
74
+ matched: true,
75
+ ids: ['tx-1', 'tx-2'],
76
+ } as never)
77
+
78
+ await GET(makeRequest({ search: 'ada@example.com' }))
79
+
80
+ expect(mockFindEntityIdsBySearchTokens).toHaveBeenCalledWith(expect.objectContaining({
81
+ entityType: 'checkout:checkout_transaction',
82
+ query: 'ada@example.com',
83
+ fields: ['email', 'first_name', 'last_name'],
84
+ scope: { tenantId: 'tenant-1', organizationId: 'org-1' },
85
+ }))
86
+ expect(capturedWhere().$or).toContainEqual({ id: { $in: ['tx-1', 'tx-2'] } })
87
+ })
88
+
89
+ it('never emits an ILIKE against the uuid id column', async () => {
90
+ await GET(makeRequest({ search: 'abc' }))
91
+
92
+ const or = capturedWhere().$or as Array<Record<string, unknown>>
93
+ expect(or.some((clause) => 'id' in clause)).toBe(false)
94
+ })
95
+
96
+ it('matches a uuid search term against the id column exactly', async () => {
97
+ const id = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'
98
+ await GET(makeRequest({ search: id }))
99
+
100
+ expect(capturedWhere().$or).toContainEqual({ id })
101
+ })
102
+
103
+ it('keeps link and status filters alongside the search predicate', async () => {
104
+ await GET(makeRequest({ search: 'ada', status: 'completed', linkId: 'link-1' }))
105
+
106
+ expect(capturedWhere()).toMatchObject({
107
+ organizationId: 'org-1',
108
+ tenantId: 'tenant-1',
109
+ status: 'completed',
110
+ linkId: 'link-1',
111
+ })
112
+ })
113
+ })
@@ -1,11 +1,15 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { findAndCountWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
3
  import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
4
+ import { findEntityIdsBySearchTokens, type SearchTokenDatabase } from '@open-mercato/shared/lib/search/tokenLookup'
4
5
  import { CheckoutLink, CheckoutTransaction } from '../../data/entities'
6
+ import { CHECKOUT_ENTITY_IDS } from '../../lib/constants'
5
7
  import { handleCheckoutRouteError, requireAdminContext, userHasCheckoutFeature } from '../helpers'
6
8
  import { checkoutTag } from '../openapi'
7
9
  import { serializeTransaction } from '../../lib/utils'
8
10
 
11
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
12
+
9
13
  export const metadata = {
10
14
  path: '/checkout/transactions',
11
15
  GET: { requireAuth: true, requireFeatures: ['checkout.view'] },
@@ -27,12 +31,32 @@ export async function GET(req: Request) {
27
31
  }
28
32
  if (linkId) where.linkId = linkId
29
33
  if (status) where.status = status
30
- if (search) where.$or = [
31
- { email: { $ilike: `%${escapeLikePattern(search)}%` } },
32
- { firstName: { $ilike: `%${escapeLikePattern(search)}%` } },
33
- { lastName: { $ilike: `%${escapeLikePattern(search)}%` } },
34
- { id: { $ilike: `%${escapeLikePattern(search)}%` } },
35
- ]
34
+ if (search) {
35
+ const pattern = `%${escapeLikePattern(search)}%`
36
+ // email/first_name/last_name are covered by the checkout encryption map,
37
+ // so an ILIKE alone compares the pattern against ciphertext and matches
38
+ // nothing once encryption is on. Union it with the token index, which
39
+ // stores hashes of the plaintext. Issue #2990.
40
+ const searchOr: Record<string, unknown>[] = [
41
+ { email: { $ilike: pattern } },
42
+ { firstName: { $ilike: pattern } },
43
+ { lastName: { $ilike: pattern } },
44
+ ]
45
+ // `id` is a uuid column: ILIKE against it has no Postgres operator and
46
+ // raises 42883, so only an exact id lookup is meaningful here.
47
+ if (UUID_PATTERN.test(search)) searchOr.push({ id: search })
48
+ const tokenMatch = await findEntityIdsBySearchTokens({
49
+ db: em.getKysely<SearchTokenDatabase>(),
50
+ entityType: CHECKOUT_ENTITY_IDS.transaction,
51
+ query: search,
52
+ fields: ['email', 'first_name', 'last_name'],
53
+ scope: { tenantId: auth.tenantId, organizationId: auth.orgId },
54
+ })
55
+ if (tokenMatch.matched && tokenMatch.ids.length) {
56
+ searchOr.push({ id: { $in: tokenMatch.ids } })
57
+ }
58
+ where.$or = searchOr
59
+ }
36
60
  const [items, total] = await findAndCountWithDecryption(
37
61
  em,
38
62
  CheckoutTransaction,
@@ -45,6 +45,36 @@ export const searchConfig: SearchModuleConfig = {
45
45
  icon: 'lucide:file-text',
46
46
  }),
47
47
  },
48
+ {
49
+ entityId: CHECKOUT_ENTITY_IDS.transaction,
50
+ enabled: true,
51
+ priority: 6,
52
+ fieldPolicy: {
53
+ searchable: ['email', 'firstName', 'lastName'],
54
+ },
55
+ buildSource: async (ctx) => ({
56
+ text: [
57
+ asSearchText(ctx.record.email),
58
+ asSearchText(ctx.record.firstName),
59
+ asSearchText(ctx.record.lastName),
60
+ ],
61
+ fields: {
62
+ email: ctx.record.email,
63
+ first_name: ctx.record.firstName,
64
+ last_name: ctx.record.lastName,
65
+ },
66
+ presenter: {
67
+ title: asSearchText(ctx.record.email),
68
+ subtitle: `${asSearchText(ctx.record.firstName)} ${asSearchText(ctx.record.lastName)}`.trim(),
69
+ },
70
+ checksumSource: { record: ctx.record },
71
+ }),
72
+ formatResult: async (ctx) => ({
73
+ title: asSearchText(ctx.record.email),
74
+ subtitle: `${asSearchText(ctx.record.firstName)} ${asSearchText(ctx.record.lastName)}`.trim(),
75
+ icon: 'lucide:receipt',
76
+ }),
77
+ },
48
78
  ],
49
79
  }
50
80
 
@@ -1,14 +1,65 @@
1
1
  import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
2
+ import { createHash } from 'node:crypto'
2
3
  import { ensureCheckoutFieldsetsAndDefinitions } from './seed/customFields'
3
4
  import { seedCheckoutExamples } from './seed/examples'
5
+ import { CHECKOUT_EXPIRY_QUEUE } from './workers/transaction-expiry.worker'
4
6
  export { DEFAULT_CHECKOUT_CUSTOMER_FIELDS } from './lib/defaults'
5
7
 
8
+ function stableUuidFromString(input: string): string {
9
+ const bytes = createHash('sha256').update(input).digest().subarray(0, 16)
10
+ bytes[6] = (bytes[6] & 0x0f) | 0x50
11
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
12
+ const hex = Buffer.from(bytes).toString('hex')
13
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
14
+ }
15
+
16
+ type SchedulerServiceLike = {
17
+ register: (registration: {
18
+ id: string
19
+ name: string
20
+ description?: string
21
+ scopeType: 'organization'
22
+ organizationId: string
23
+ tenantId: string
24
+ scheduleType: 'cron' | 'interval'
25
+ scheduleValue: string
26
+ timezone?: string
27
+ targetType: 'queue'
28
+ targetQueue: string
29
+ targetPayload?: Record<string, unknown>
30
+ sourceType: 'module'
31
+ sourceModule: string
32
+ isEnabled?: boolean
33
+ }) => Promise<void>
34
+ }
35
+
6
36
  export const setup: ModuleSetupConfig = {
7
37
  async seedDefaults(ctx) {
8
38
  await ensureCheckoutFieldsetsAndDefinitions(ctx.em, {
9
39
  tenantId: ctx.tenantId,
10
40
  organizationId: ctx.organizationId,
11
41
  })
42
+
43
+ const cradle = ctx.container as { hasRegistration?: (name: string) => boolean }
44
+ if (typeof cradle.hasRegistration !== 'function' || !cradle.hasRegistration('schedulerService')) return
45
+
46
+ const schedulerService = ctx.container.resolve('schedulerService') as SchedulerServiceLike
47
+ await schedulerService.register({
48
+ id: stableUuidFromString(`checkout:transaction-expiry:${ctx.tenantId}:${ctx.organizationId}`),
49
+ name: 'Checkout transaction expiry',
50
+ description: 'Marks stale checkout transactions as expired and runs usage limit updates.',
51
+ scopeType: 'organization',
52
+ organizationId: ctx.organizationId,
53
+ tenantId: ctx.tenantId,
54
+ scheduleType: 'interval',
55
+ scheduleValue: '10m',
56
+ timezone: 'UTC',
57
+ targetType: 'queue',
58
+ targetQueue: CHECKOUT_EXPIRY_QUEUE,
59
+ sourceType: 'module',
60
+ sourceModule: 'checkout',
61
+ isEnabled: true,
62
+ })
12
63
  },
13
64
 
14
65
  defaultRoleFeatures: {
@@ -0,0 +1,29 @@
1
+ import handle from '../search-reindex-transaction-created'
2
+
3
+ const emitEvent = jest.fn(async () => undefined)
4
+ const context = { resolve: jest.fn(() => ({ emitEvent })) }
5
+
6
+ describe('checkout transaction search reindex subscriber', () => {
7
+ beforeEach(() => jest.clearAllMocks())
8
+
9
+ it('bridges transaction creation to the query index', async () => {
10
+ await handle({ transactionId: 'transaction-1', tenantId: 'tenant-1', organizationId: 'org-1' }, context)
11
+
12
+ expect(emitEvent).toHaveBeenCalledWith('query_index.upsert_one', {
13
+ entityType: 'checkout:checkout_transaction',
14
+ recordId: 'transaction-1',
15
+ organizationId: 'org-1',
16
+ tenantId: 'tenant-1',
17
+ crudAction: 'created',
18
+ coverageBaseDelta: 1,
19
+ }, { tenantId: 'tenant-1', organizationId: 'org-1' })
20
+ })
21
+
22
+ it('skips incomplete payloads and unavailable event buses', async () => {
23
+ await handle({ transactionId: 'transaction-1' }, context)
24
+ await handle({ transactionId: 'transaction-1', tenantId: 'tenant-1' }, {
25
+ resolve: () => { throw new Error('unavailable') },
26
+ })
27
+ expect(emitEvent).not.toHaveBeenCalled()
28
+ })
29
+ })
@@ -0,0 +1,39 @@
1
+ export const metadata = {
2
+ event: 'checkout.transaction.created',
3
+ persistent: false,
4
+ id: 'checkout:query-index-reindex-transaction-created',
5
+ }
6
+
7
+ type Payload = {
8
+ transactionId?: string
9
+ tenantId?: string | null
10
+ organizationId?: string | null
11
+ }
12
+
13
+ type EventBus = { emitEvent: (name: string, body: unknown, options?: unknown) => Promise<void> }
14
+ type HandlerContext = { resolve: <T = unknown>(name: string) => T }
15
+
16
+ export default async function handle(payload: Payload, ctx: HandlerContext): Promise<void> {
17
+ const recordId = typeof payload.transactionId === 'string' ? payload.transactionId : null
18
+ const tenantId = typeof payload.tenantId === 'string' ? payload.tenantId : null
19
+ if (!recordId || !tenantId) return
20
+
21
+ let eventBus: EventBus | null = null
22
+ try {
23
+ eventBus = ctx.resolve<EventBus>('eventBus')
24
+ } catch {
25
+ return
26
+ }
27
+
28
+ await eventBus.emitEvent('query_index.upsert_one', {
29
+ entityType: 'checkout:checkout_transaction',
30
+ recordId,
31
+ organizationId: payload.organizationId ?? null,
32
+ tenantId,
33
+ crudAction: 'created',
34
+ coverageBaseDelta: 1,
35
+ }, {
36
+ tenantId,
37
+ organizationId: payload.organizationId ?? null,
38
+ }).catch(() => undefined)
39
+ }
@@ -0,0 +1,124 @@
1
+ import { beforeEach, describe, expect, it, jest } from '@jest/globals'
2
+ import type { JobContext, QueuedJob } from '@open-mercato/queue'
3
+ import { CheckoutTransaction } from '../../data/entities'
4
+ import type { CheckoutExpiryJob } from '../transaction-expiry.worker'
5
+
6
+ const findWithDecryption = jest.fn()
7
+ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
8
+ findWithDecryption: (...args: unknown[]) => findWithDecryption(...args),
9
+ }))
10
+
11
+ describe('checkout transaction-expiry worker', () => {
12
+ const mockCommandBus = {
13
+ execute: jest.fn().mockResolvedValue({ ok: true }),
14
+ }
15
+
16
+ type MockEntityManager = {
17
+ fork: () => MockEntityManager
18
+ }
19
+
20
+ const mockEm: MockEntityManager = {
21
+ fork: () => mockEm,
22
+ }
23
+
24
+ const resolve = <ResolvedValue = unknown>(name: string): ResolvedValue => {
25
+ if (name === 'em') return mockEm as ResolvedValue
26
+ if (name === 'commandBus') return mockCommandBus as ResolvedValue
27
+ throw new Error(`[internal] Missing dependency: ${name}`)
28
+ }
29
+
30
+ const mockContext: JobContext & { resolve: typeof resolve } = {
31
+ resolve,
32
+ jobId: 'job-1',
33
+ attemptNumber: 1,
34
+ queueName: 'checkout-transaction-expiry',
35
+ }
36
+
37
+ const baseJob = {
38
+ id: 'job-1',
39
+ createdAt: '2026-08-01T00:00:00.000Z',
40
+ }
41
+
42
+ beforeEach(() => {
43
+ jest.clearAllMocks()
44
+ })
45
+
46
+ it('throws an error if tenantId or organizationId is missing', async () => {
47
+ const { default: handle } = await import('../transaction-expiry.worker')
48
+
49
+ await expect(
50
+ handle(
51
+ {
52
+ ...baseJob,
53
+ payload: { batchSize: 10 },
54
+ } as unknown as QueuedJob<CheckoutExpiryJob>,
55
+ mockContext,
56
+ ),
57
+ ).rejects.toThrow('[internal] tenantId and organizationId are required in CheckoutExpiryJob')
58
+
59
+ expect(findWithDecryption).not.toHaveBeenCalled()
60
+ })
61
+
62
+ it('queries only transactions matching the payload tenantId and organizationId and passes decryption scope', async () => {
63
+ const { default: handle } = await import('../transaction-expiry.worker')
64
+
65
+ const mockTransactions = [
66
+ {
67
+ id: 'txn-1',
68
+ organizationId: 'org-123',
69
+ tenantId: 'tenant-456',
70
+ },
71
+ {
72
+ id: 'txn-2',
73
+ organizationId: 'org-123',
74
+ tenantId: 'tenant-456',
75
+ },
76
+ ]
77
+
78
+ findWithDecryption.mockResolvedValueOnce(mockTransactions)
79
+
80
+ await handle(
81
+ {
82
+ ...baseJob,
83
+ payload: {
84
+ batchSize: 50,
85
+ tenantId: 'tenant-456',
86
+ organizationId: 'org-123',
87
+ },
88
+ } satisfies QueuedJob<CheckoutExpiryJob>,
89
+ mockContext,
90
+ )
91
+
92
+ expect(findWithDecryption).toHaveBeenCalledWith(
93
+ mockEm,
94
+ CheckoutTransaction,
95
+ expect.objectContaining({
96
+ status: 'processing',
97
+ tenantId: 'tenant-456',
98
+ organizationId: 'org-123',
99
+ }),
100
+ expect.objectContaining({
101
+ limit: 50,
102
+ }),
103
+ {
104
+ tenantId: 'tenant-456',
105
+ organizationId: 'org-123',
106
+ },
107
+ )
108
+
109
+ expect(mockCommandBus.execute).toHaveBeenCalledTimes(2)
110
+ expect(mockCommandBus.execute).toHaveBeenNthCalledWith(
111
+ 1,
112
+ 'checkout.transaction.updateStatus',
113
+ expect.objectContaining({
114
+ input: {
115
+ id: 'txn-1',
116
+ status: 'expired',
117
+ paymentStatus: 'expired',
118
+ organizationId: 'org-123',
119
+ tenantId: 'tenant-456',
120
+ },
121
+ }),
122
+ )
123
+ })
124
+ })
@@ -14,6 +14,8 @@ const EXPIRY_TIMEOUT_MS = 2 * 60 * 60 * 1000 // 2 hours
14
14
 
15
15
  export type CheckoutExpiryJob = {
16
16
  batchSize?: number
17
+ tenantId: string
18
+ organizationId: string
17
19
  }
18
20
 
19
21
  export const metadata: WorkerMeta = {
@@ -32,14 +34,23 @@ export default async function handle(job: QueuedJob<CheckoutExpiryJob>, ctx: Han
32
34
  const batchSize = job.payload?.batchSize ?? 100
33
35
  const cutoff = new Date(Date.now() - EXPIRY_TIMEOUT_MS)
34
36
 
37
+ if (!job.payload?.tenantId || !job.payload?.organizationId) {
38
+ throw new Error('[internal] tenantId and organizationId are required in CheckoutExpiryJob')
39
+ }
40
+
41
+ const { tenantId, organizationId } = job.payload
42
+
35
43
  const staleTransactions = await findWithDecryption(
36
44
  em,
37
45
  CheckoutTransaction,
38
46
  {
39
47
  status: 'processing',
40
48
  createdAt: { $lt: cutoff },
49
+ tenantId,
50
+ organizationId,
41
51
  },
42
52
  { limit: batchSize, orderBy: { createdAt: 'ASC' } },
53
+ { tenantId, organizationId },
43
54
  )
44
55
 
45
56
  for (const transaction of staleTransactions) {