@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/checkout/__integration__/TC-CHKT-042.spec.js +39 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-042.spec.js.map +7 -0
- package/dist/modules/checkout/api/transactions/route.js +23 -6
- package/dist/modules/checkout/api/transactions/route.js.map +2 -2
- package/dist/modules/checkout/search.js +30 -0
- package/dist/modules/checkout/search.js.map +2 -2
- package/dist/modules/checkout/setup.js +28 -0
- package/dist/modules/checkout/setup.js.map +2 -2
- package/dist/modules/checkout/subscribers/search-reindex-transaction-created.js +32 -0
- package/dist/modules/checkout/subscribers/search-reindex-transaction-created.js.map +7 -0
- package/dist/modules/checkout/workers/transaction-expiry.worker.js +9 -2
- package/dist/modules/checkout/workers/transaction-expiry.worker.js.map +2 -2
- package/package.json +5 -5
- package/src/modules/checkout/__integration__/TC-CHKT-042.spec.ts +41 -0
- package/src/modules/checkout/__tests__/search.test.ts +21 -0
- package/src/modules/checkout/__tests__/setup-schedules.test.ts +99 -0
- package/src/modules/checkout/api/transactions/__tests__/route.test.ts +113 -0
- package/src/modules/checkout/api/transactions/route.ts +30 -6
- package/src/modules/checkout/search.ts +30 -0
- package/src/modules/checkout/setup.ts +51 -0
- package/src/modules/checkout/subscribers/__tests__/search-reindex-transaction-created.test.ts +29 -0
- package/src/modules/checkout/subscribers/search-reindex-transaction-created.ts +39 -0
- package/src/modules/checkout/workers/__tests__/transaction-expiry.worker.test.ts +124 -0
- package/src/modules/checkout/workers/transaction-expiry.worker.ts +11 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:checkout] found
|
|
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)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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)
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,4BAA4B,0BAA0B;AAC/D,SAAS,yBAAyB;AAClC,SAAS,cAAc,2BAA2B;AAClD,SAAS,0BAA0B,qBAAqB,8BAA8B;AACtF,SAAS,mBAAmB;AAC5B,SAAS,4BAA4B;
|
|
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": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,4BAA4B,0BAA0B;AAC/D,SAAS,yBAAyB;AAClC,SAAS,mCAA6D;AACtE,SAAS,cAAc,2BAA2B;AAClD,SAAS,2BAA2B;AACpC,SAAS,0BAA0B,qBAAqB,8BAA8B;AACtF,SAAS,mBAAmB;AAC5B,SAAS,4BAA4B;AAErC,MAAM,eAAe;AAEd,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,eAAe,EAAE;AAC/D;AAEA,eAAsB,IAAI,KAAc;AACtC,MAAI;AACF,UAAM,EAAE,MAAM,WAAW,GAAG,IAAI,MAAM,oBAAoB,GAAG;AAC7D,UAAM,aAAa,MAAM,uBAAuB,WAAW,MAAM,kBAAkB;AACnF,UAAM,MAAM,IAAI,IAAI,IAAI,GAAG;AAC3B,UAAM,OAAO,KAAK,IAAI,GAAG,OAAO,IAAI,aAAa,IAAI,MAAM,KAAK,GAAG,CAAC;AACpE,UAAM,WAAW,KAAK,IAAI,KAAK,KAAK,IAAI,GAAG,OAAO,IAAI,aAAa,IAAI,UAAU,KAAK,IAAI,CAAC,CAAC;AAC5F,UAAM,UAAU,IAAI,aAAa,IAAI,QAAQ,KAAK,IAAI,KAAK;AAC3D,UAAM,SAAS,IAAI,aAAa,IAAI,QAAQ;AAC5C,UAAM,SAAS,IAAI,aAAa,IAAI,QAAQ;AAC5C,UAAM,QAAiC;AAAA,MACrC,gBAAgB,KAAK;AAAA,MACrB,UAAU,KAAK;AAAA,IACjB;AACA,QAAI,OAAQ,OAAM,SAAS;AAC3B,QAAI,OAAQ,OAAM,SAAS;AAC3B,QAAI,QAAQ;AACV,YAAM,UAAU,IAAI,kBAAkB,MAAM,CAAC;AAK7C,YAAM,WAAsC;AAAA,QAC1C,EAAE,OAAO,EAAE,QAAQ,QAAQ,EAAE;AAAA,QAC7B,EAAE,WAAW,EAAE,QAAQ,QAAQ,EAAE;AAAA,QACjC,EAAE,UAAU,EAAE,QAAQ,QAAQ,EAAE;AAAA,MAClC;AAGA,UAAI,aAAa,KAAK,MAAM,EAAG,UAAS,KAAK,EAAE,IAAI,OAAO,CAAC;AAC3D,YAAM,aAAa,MAAM,4BAA4B;AAAA,QACnD,IAAI,GAAG,UAA+B;AAAA,QACtC,YAAY,oBAAoB;AAAA,QAChC,OAAO;AAAA,QACP,QAAQ,CAAC,SAAS,cAAc,WAAW;AAAA,QAC3C,OAAO,EAAE,UAAU,KAAK,UAAU,gBAAgB,KAAK,MAAM;AAAA,MAC/D,CAAC;AACD,UAAI,WAAW,WAAW,WAAW,IAAI,QAAQ;AAC/C,iBAAS,KAAK,EAAE,IAAI,EAAE,KAAK,WAAW,IAAI,EAAE,CAAC;AAAA,MAC/C;AACA,YAAM,MAAM;AAAA,IACd;AACA,UAAM,CAAC,OAAO,KAAK,IAAI,MAAM;AAAA,MAC3B;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,QACE,SAAS,EAAE,WAAW,OAAO;AAAA,QAC7B,SAAS,OAAO,KAAK;AAAA,QACrB,OAAO;AAAA,MACT;AAAA,MACA,EAAE,gBAAgB,KAAK,OAAO,UAAU,KAAK,SAAS;AAAA,IACxD;AACA,UAAM,UAAU,MAAM,KAAK,IAAI,IAAI,MAAM,IAAI,CAAC,SAAS,KAAK,MAAM,CAAC,CAAC;AACpE,UAAM,QAAQ,QAAQ,SAClB,MAAM,mBAAmB,IAAI,cAAc;AAAA,MAC3C,IAAI,EAAE,KAAK,QAAQ;AAAA,MACnB,gBAAgB,KAAK;AAAA,MACrB,UAAU,KAAK;AAAA,MACf,WAAW;AAAA,IACb,GAAG,QAAW,EAAE,gBAAgB,KAAK,OAAO,UAAU,KAAK,SAAS,CAAC,IACnE,CAAC;AACL,UAAM,UAAU,IAAI,IAAI,MAAM,IAAI,CAAC,SAAS,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC;AAC5D,WAAO,aAAa,KAAK;AAAA,MACvB,OAAO,MAAM,IAAI,CAAC,SAAS,qBAAqB,MAAM,QAAQ,IAAI,KAAK,MAAM,KAAK,MAAM,UAAU,CAAC;AAAA,MACnG;AAAA,MACA;AAAA,MACA;AAAA,MACA,YAAY,KAAK,IAAI,GAAG,KAAK,KAAK,QAAQ,QAAQ,CAAC;AAAA,MACnD;AAAA,IACF,CAAC;AAAA,EACH,SAAS,OAAO;AACd,WAAO,yBAAyB,KAAK;AAAA,EACvC;AACF;AAEO,MAAM,UAAU;AAAA,EACrB,MAAM,CAAC,WAAW;AACpB;AAEA,IAAO,gBAAQ;",
|
|
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;
|
|
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;
|
|
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.
|
|
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.
|
|
66
|
-
"@open-mercato/ui": "0.6.7-develop.
|
|
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.
|
|
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.
|
|
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)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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) {
|