@open-mercato/checkout 0.6.7 → 0.6.8-develop.6874.1.982d6097d8

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 (75) 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/__integration__/TC-CHKT-043-concurrent-status-update.spec.js +62 -0
  5. package/dist/modules/checkout/__integration__/TC-CHKT-043-concurrent-status-update.spec.js.map +7 -0
  6. package/dist/modules/checkout/api/pay/[slug]/submit/route.js +43 -28
  7. package/dist/modules/checkout/api/pay/[slug]/submit/route.js.map +2 -2
  8. package/dist/modules/checkout/api/transactions/route.js +23 -6
  9. package/dist/modules/checkout/api/transactions/route.js.map +2 -2
  10. package/dist/modules/checkout/backend/checkout/pay-links/page.js +2 -1
  11. package/dist/modules/checkout/backend/checkout/pay-links/page.js.map +2 -2
  12. package/dist/modules/checkout/backend/checkout/templates/page.js +2 -1
  13. package/dist/modules/checkout/backend/checkout/templates/page.js.map +2 -2
  14. package/dist/modules/checkout/backend/checkout/transactions/page.js +2 -1
  15. package/dist/modules/checkout/backend/checkout/transactions/page.js.map +2 -2
  16. package/dist/modules/checkout/commands/links.js +1 -1
  17. package/dist/modules/checkout/commands/links.js.map +2 -2
  18. package/dist/modules/checkout/commands/transactions.js +45 -13
  19. package/dist/modules/checkout/commands/transactions.js.map +2 -2
  20. package/dist/modules/checkout/components/CustomerFieldsEditor.js +2 -2
  21. package/dist/modules/checkout/components/CustomerFieldsEditor.js.map +2 -2
  22. package/dist/modules/checkout/components/GatewaySettingsFields.js +2 -2
  23. package/dist/modules/checkout/components/GatewaySettingsFields.js.map +2 -2
  24. package/dist/modules/checkout/components/LinkTemplateForm.js +8 -8
  25. package/dist/modules/checkout/components/LinkTemplateForm.js.map +2 -2
  26. package/dist/modules/checkout/components/LogoUploadField.js +1 -1
  27. package/dist/modules/checkout/components/LogoUploadField.js.map +2 -2
  28. package/dist/modules/checkout/components/PayPage.js +39 -38
  29. package/dist/modules/checkout/components/PayPage.js.map +2 -2
  30. package/dist/modules/checkout/extension-points.js +68 -0
  31. package/dist/modules/checkout/extension-points.js.map +7 -0
  32. package/dist/modules/checkout/lib/transaction-status-machine.js +31 -0
  33. package/dist/modules/checkout/lib/transaction-status-machine.js.map +7 -0
  34. package/dist/modules/checkout/search.js +44 -6
  35. package/dist/modules/checkout/search.js.map +2 -2
  36. package/dist/modules/checkout/setup.js +28 -0
  37. package/dist/modules/checkout/setup.js.map +2 -2
  38. package/dist/modules/checkout/subscribers/search-reindex-transaction-created.js +32 -0
  39. package/dist/modules/checkout/subscribers/search-reindex-transaction-created.js.map +7 -0
  40. package/dist/modules/checkout/workers/transaction-expiry.worker.js +9 -2
  41. package/dist/modules/checkout/workers/transaction-expiry.worker.js.map +2 -2
  42. package/package.json +10 -9
  43. package/src/modules/checkout/__integration__/TC-CHKT-042.spec.ts +41 -0
  44. package/src/modules/checkout/__integration__/TC-CHKT-043-concurrent-status-update.spec.ts +112 -0
  45. package/src/modules/checkout/__tests__/search.test.ts +21 -0
  46. package/src/modules/checkout/__tests__/setup-schedules.test.ts +99 -0
  47. package/src/modules/checkout/api/pay/[slug]/submit/__tests__/route.test.ts +77 -0
  48. package/src/modules/checkout/api/pay/[slug]/submit/route.ts +58 -28
  49. package/src/modules/checkout/api/transactions/__tests__/route.test.ts +113 -0
  50. package/src/modules/checkout/api/transactions/route.ts +30 -6
  51. package/src/modules/checkout/backend/checkout/pay-links/page.tsx +2 -1
  52. package/src/modules/checkout/backend/checkout/templates/page.tsx +2 -1
  53. package/src/modules/checkout/backend/checkout/transactions/page.tsx +2 -1
  54. package/src/modules/checkout/commands/__tests__/update-transaction-status.test.ts +363 -0
  55. package/src/modules/checkout/commands/links.ts +5 -1
  56. package/src/modules/checkout/commands/transactions.ts +68 -13
  57. package/src/modules/checkout/components/CustomerFieldsEditor.tsx +2 -2
  58. package/src/modules/checkout/components/GatewaySettingsFields.tsx +2 -2
  59. package/src/modules/checkout/components/LinkTemplateForm.tsx +8 -8
  60. package/src/modules/checkout/components/LogoUploadField.tsx +1 -1
  61. package/src/modules/checkout/components/PayPage.tsx +38 -37
  62. package/src/modules/checkout/extension-points.ts +67 -0
  63. package/src/modules/checkout/i18n/de.json +1 -0
  64. package/src/modules/checkout/i18n/en.json +1 -0
  65. package/src/modules/checkout/i18n/es.json +1 -0
  66. package/src/modules/checkout/i18n/ko.json +463 -0
  67. package/src/modules/checkout/i18n/pl.json +1 -0
  68. package/src/modules/checkout/lib/__tests__/transaction-status-machine.test.ts +152 -0
  69. package/src/modules/checkout/lib/transaction-status-machine.ts +71 -0
  70. package/src/modules/checkout/search.ts +44 -6
  71. package/src/modules/checkout/setup.ts +51 -0
  72. package/src/modules/checkout/subscribers/__tests__/search-reindex-transaction-created.test.ts +29 -0
  73. package/src/modules/checkout/subscribers/search-reindex-transaction-created.ts +39 -0
  74. package/src/modules/checkout/workers/__tests__/transaction-expiry.worker.test.ts +124 -0
  75. package/src/modules/checkout/workers/transaction-expiry.worker.ts +11 -0
@@ -0,0 +1,68 @@
1
+ import {
2
+ componentExtensionHost,
3
+ dataTableExtensionHost,
4
+ defineModuleExtensionPoints,
5
+ injectionExtensionHost
6
+ } from "@open-mercato/shared/modules/widgets/extension-points";
7
+ const payPageSpot = (spotId) => injectionExtensionHost({
8
+ family: "detail",
9
+ spotId,
10
+ supported: ["render-widget"],
11
+ contextContract: "checkout.pay-page.v1",
12
+ source: "components/PayPage.tsx"
13
+ });
14
+ const payPageComponent = (componentId) => componentExtensionHost({
15
+ componentId,
16
+ propsContract: "checkout.pay-page.v1",
17
+ source: "components/PayPage.tsx"
18
+ });
19
+ const extensionPoints = defineModuleExtensionPoints({
20
+ moduleId: "checkout",
21
+ hosts: {
22
+ linksTable: dataTableExtensionHost({ tableId: "checkout-links", source: "backend/checkout/pay-links/page.tsx" }),
23
+ templatesTable: dataTableExtensionHost({ tableId: "checkout-templates", source: "backend/checkout/templates/page.tsx" }),
24
+ transactionsTable: dataTableExtensionHost({ tableId: "checkout-transactions", source: "backend/checkout/transactions/page.tsx" }),
25
+ gatewayWidgetBefore: payPageSpot("checkout.pay-page:gateway-widget:before"),
26
+ gatewayRendererBefore: payPageSpot("checkout.pay-page:gateway-widget:renderer:before"),
27
+ gatewayRendererAfter: payPageSpot("checkout.pay-page:gateway-widget:renderer:after"),
28
+ gatewayActionsBefore: payPageSpot("checkout.pay-page:gateway-widget:actions:before"),
29
+ gatewayActionsAfter: payPageSpot("checkout.pay-page:gateway-widget:actions:after"),
30
+ gatewayWidgetAfter: payPageSpot("checkout.pay-page:gateway-widget:after"),
31
+ headerBefore: payPageSpot("checkout.pay-page:header:before"),
32
+ headerAfter: payPageSpot("checkout.pay-page:header:after"),
33
+ descriptionAfter: payPageSpot("checkout.pay-page:description:after"),
34
+ customerFieldsBefore: payPageSpot("checkout.pay-page:customer-fields:before"),
35
+ customerFieldsAfter: payPageSpot("checkout.pay-page:customer-fields:after"),
36
+ pricingBefore: payPageSpot("checkout.pay-page:pricing:before"),
37
+ pricingAfter: payPageSpot("checkout.pay-page:pricing:after"),
38
+ summaryBefore: payPageSpot("checkout.pay-page:summary:before"),
39
+ summaryAfter: payPageSpot("checkout.pay-page:summary:after"),
40
+ legalConsentBefore: payPageSpot("checkout.pay-page:legal-consent:before"),
41
+ legalConsentAfter: payPageSpot("checkout.pay-page:legal-consent:after"),
42
+ submitBefore: payPageSpot("checkout.pay-page:submit:before"),
43
+ submitAfter: payPageSpot("checkout.pay-page:submit:after"),
44
+ helpBefore: payPageSpot("checkout.pay-page:help:before"),
45
+ helpAfter: payPageSpot("checkout.pay-page:help:after"),
46
+ paymentBefore: payPageSpot("checkout.pay-page:payment:before"),
47
+ paymentAfter: payPageSpot("checkout.pay-page:payment:after"),
48
+ footerBefore: payPageSpot("checkout.pay-page:footer:before"),
49
+ footerAfter: payPageSpot("checkout.pay-page:footer:after"),
50
+ payPage: payPageComponent("page:checkout.pay-page"),
51
+ header: payPageComponent("section:checkout.pay-page.header"),
52
+ description: payPageComponent("section:checkout.pay-page.description"),
53
+ summary: payPageComponent("section:checkout.pay-page.summary"),
54
+ pricing: payPageComponent("section:checkout.pay-page.pricing"),
55
+ payment: payPageComponent("section:checkout.pay-page.payment"),
56
+ customerForm: payPageComponent("section:checkout.pay-page.customer-form"),
57
+ legalConsent: payPageComponent("section:checkout.pay-page.legal-consent"),
58
+ gatewayForm: payPageComponent("section:checkout.pay-page.gateway-form"),
59
+ help: payPageComponent("section:checkout.pay-page.help"),
60
+ footer: payPageComponent("section:checkout.pay-page.footer")
61
+ }
62
+ });
63
+ var extension_points_default = extensionPoints;
64
+ export {
65
+ extension_points_default as default,
66
+ extensionPoints
67
+ };
68
+ //# sourceMappingURL=extension-points.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/modules/checkout/extension-points.ts"],
4
+ "sourcesContent": ["import {\n componentExtensionHost,\n dataTableExtensionHost,\n defineModuleExtensionPoints,\n injectionExtensionHost,\n} from '@open-mercato/shared/modules/widgets/extension-points'\n\nconst payPageSpot = (spotId: string) => injectionExtensionHost({\n family: 'detail',\n spotId,\n supported: ['render-widget'],\n contextContract: 'checkout.pay-page.v1',\n source: 'components/PayPage.tsx',\n})\n\nconst payPageComponent = (componentId: string) => componentExtensionHost({\n componentId,\n propsContract: 'checkout.pay-page.v1',\n source: 'components/PayPage.tsx',\n})\n\nexport const extensionPoints = defineModuleExtensionPoints({\n moduleId: 'checkout',\n hosts: {\n linksTable: dataTableExtensionHost({ tableId: 'checkout-links', source: 'backend/checkout/pay-links/page.tsx' }),\n templatesTable: dataTableExtensionHost({ tableId: 'checkout-templates', source: 'backend/checkout/templates/page.tsx' }),\n transactionsTable: dataTableExtensionHost({ tableId: 'checkout-transactions', source: 'backend/checkout/transactions/page.tsx' }),\n gatewayWidgetBefore: payPageSpot('checkout.pay-page:gateway-widget:before'),\n gatewayRendererBefore: payPageSpot('checkout.pay-page:gateway-widget:renderer:before'),\n gatewayRendererAfter: payPageSpot('checkout.pay-page:gateway-widget:renderer:after'),\n gatewayActionsBefore: payPageSpot('checkout.pay-page:gateway-widget:actions:before'),\n gatewayActionsAfter: payPageSpot('checkout.pay-page:gateway-widget:actions:after'),\n gatewayWidgetAfter: payPageSpot('checkout.pay-page:gateway-widget:after'),\n headerBefore: payPageSpot('checkout.pay-page:header:before'),\n headerAfter: payPageSpot('checkout.pay-page:header:after'),\n descriptionAfter: payPageSpot('checkout.pay-page:description:after'),\n customerFieldsBefore: payPageSpot('checkout.pay-page:customer-fields:before'),\n customerFieldsAfter: payPageSpot('checkout.pay-page:customer-fields:after'),\n pricingBefore: payPageSpot('checkout.pay-page:pricing:before'),\n pricingAfter: payPageSpot('checkout.pay-page:pricing:after'),\n summaryBefore: payPageSpot('checkout.pay-page:summary:before'),\n summaryAfter: payPageSpot('checkout.pay-page:summary:after'),\n legalConsentBefore: payPageSpot('checkout.pay-page:legal-consent:before'),\n legalConsentAfter: payPageSpot('checkout.pay-page:legal-consent:after'),\n submitBefore: payPageSpot('checkout.pay-page:submit:before'),\n submitAfter: payPageSpot('checkout.pay-page:submit:after'),\n helpBefore: payPageSpot('checkout.pay-page:help:before'),\n helpAfter: payPageSpot('checkout.pay-page:help:after'),\n paymentBefore: payPageSpot('checkout.pay-page:payment:before'),\n paymentAfter: payPageSpot('checkout.pay-page:payment:after'),\n footerBefore: payPageSpot('checkout.pay-page:footer:before'),\n footerAfter: payPageSpot('checkout.pay-page:footer:after'),\n payPage: payPageComponent('page:checkout.pay-page'),\n header: payPageComponent('section:checkout.pay-page.header'),\n description: payPageComponent('section:checkout.pay-page.description'),\n summary: payPageComponent('section:checkout.pay-page.summary'),\n pricing: payPageComponent('section:checkout.pay-page.pricing'),\n payment: payPageComponent('section:checkout.pay-page.payment'),\n customerForm: payPageComponent('section:checkout.pay-page.customer-form'),\n legalConsent: payPageComponent('section:checkout.pay-page.legal-consent'),\n gatewayForm: payPageComponent('section:checkout.pay-page.gateway-form'),\n help: payPageComponent('section:checkout.pay-page.help'),\n footer: payPageComponent('section:checkout.pay-page.footer'),\n },\n})\n\nexport default extensionPoints\n"],
5
+ "mappings": "AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,MAAM,cAAc,CAAC,WAAmB,uBAAuB;AAAA,EAC7D,QAAQ;AAAA,EACR;AAAA,EACA,WAAW,CAAC,eAAe;AAAA,EAC3B,iBAAiB;AAAA,EACjB,QAAQ;AACV,CAAC;AAED,MAAM,mBAAmB,CAAC,gBAAwB,uBAAuB;AAAA,EACvE;AAAA,EACA,eAAe;AAAA,EACf,QAAQ;AACV,CAAC;AAEM,MAAM,kBAAkB,4BAA4B;AAAA,EACzD,UAAU;AAAA,EACV,OAAO;AAAA,IACL,YAAY,uBAAuB,EAAE,SAAS,kBAAkB,QAAQ,sCAAsC,CAAC;AAAA,IAC/G,gBAAgB,uBAAuB,EAAE,SAAS,sBAAsB,QAAQ,sCAAsC,CAAC;AAAA,IACvH,mBAAmB,uBAAuB,EAAE,SAAS,yBAAyB,QAAQ,yCAAyC,CAAC;AAAA,IAChI,qBAAqB,YAAY,yCAAyC;AAAA,IAC1E,uBAAuB,YAAY,kDAAkD;AAAA,IACrF,sBAAsB,YAAY,iDAAiD;AAAA,IACnF,sBAAsB,YAAY,iDAAiD;AAAA,IACnF,qBAAqB,YAAY,gDAAgD;AAAA,IACjF,oBAAoB,YAAY,wCAAwC;AAAA,IACxE,cAAc,YAAY,iCAAiC;AAAA,IAC3D,aAAa,YAAY,gCAAgC;AAAA,IACzD,kBAAkB,YAAY,qCAAqC;AAAA,IACnE,sBAAsB,YAAY,0CAA0C;AAAA,IAC5E,qBAAqB,YAAY,yCAAyC;AAAA,IAC1E,eAAe,YAAY,kCAAkC;AAAA,IAC7D,cAAc,YAAY,iCAAiC;AAAA,IAC3D,eAAe,YAAY,kCAAkC;AAAA,IAC7D,cAAc,YAAY,iCAAiC;AAAA,IAC3D,oBAAoB,YAAY,wCAAwC;AAAA,IACxE,mBAAmB,YAAY,uCAAuC;AAAA,IACtE,cAAc,YAAY,iCAAiC;AAAA,IAC3D,aAAa,YAAY,gCAAgC;AAAA,IACzD,YAAY,YAAY,+BAA+B;AAAA,IACvD,WAAW,YAAY,8BAA8B;AAAA,IACrD,eAAe,YAAY,kCAAkC;AAAA,IAC7D,cAAc,YAAY,iCAAiC;AAAA,IAC3D,cAAc,YAAY,iCAAiC;AAAA,IAC3D,aAAa,YAAY,gCAAgC;AAAA,IACzD,SAAS,iBAAiB,wBAAwB;AAAA,IAClD,QAAQ,iBAAiB,kCAAkC;AAAA,IAC3D,aAAa,iBAAiB,uCAAuC;AAAA,IACrE,SAAS,iBAAiB,mCAAmC;AAAA,IAC7D,SAAS,iBAAiB,mCAAmC;AAAA,IAC7D,SAAS,iBAAiB,mCAAmC;AAAA,IAC7D,cAAc,iBAAiB,yCAAyC;AAAA,IACxE,cAAc,iBAAiB,yCAAyC;AAAA,IACxE,aAAa,iBAAiB,wCAAwC;AAAA,IACtE,MAAM,iBAAiB,gCAAgC;AAAA,IACvD,QAAQ,iBAAiB,kCAAkC;AAAA,EAC7D;AACF,CAAC;AAED,IAAO,2BAAQ;",
6
+ "names": []
7
+ }
@@ -0,0 +1,31 @@
1
+ import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
2
+ const VALID_CHECKOUT_TRANSITIONS = {
3
+ pending: ["processing", "completed", "failed", "cancelled", "expired"],
4
+ processing: ["completed", "failed", "cancelled", "expired"],
5
+ // Terminal states — no transitions out
6
+ completed: [],
7
+ failed: [],
8
+ cancelled: [],
9
+ expired: []
10
+ };
11
+ function isValidCheckoutStatusTransition(from, to) {
12
+ if (from === to) return true;
13
+ const allowed = VALID_CHECKOUT_TRANSITIONS[from];
14
+ if (!allowed) return false;
15
+ return allowed.includes(to);
16
+ }
17
+ function assertValidCheckoutStatusTransition(from, to) {
18
+ if (isValidCheckoutStatusTransition(from, to)) return;
19
+ throw new CrudHttpError(409, {
20
+ error: `[internal] Cannot transition payment from "${from}" to "${to}"`,
21
+ code: "invalid_status_transition",
22
+ currentStatus: from,
23
+ requestedStatus: to
24
+ });
25
+ }
26
+ export {
27
+ VALID_CHECKOUT_TRANSITIONS,
28
+ assertValidCheckoutStatusTransition,
29
+ isValidCheckoutStatusTransition
30
+ };
31
+ //# sourceMappingURL=transaction-status-machine.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/checkout/lib/transaction-status-machine.ts"],
4
+ "sourcesContent": ["import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { CheckoutTransaction } from '../data/entities'\n\n/**\n * Allowed status transitions for a CheckoutTransaction.\n *\n * Terminal states (completed, failed, cancelled, expired) have an empty\n * allowed-set, meaning NO transition out of them is permitted. This prevents\n * the race condition where the submit route's late `updateStatus('processing')`\n * call overwrites a `'completed'` status that the gateway webhook set first.\n *\n * Allowed path:\n * pending \u2500\u2500\u25BA processing (transaction created, payment session initiated)\n * pending \u2500\u2500\u25BA completed (captured webhook arrives before processing state)\n * processing \u2500\u2500\u25BA completed (webhook: payment captured)\n * processing \u2500\u2500\u25BA failed (webhook or session error)\n * processing \u2500\u2500\u25BA cancelled (customer or merchant cancellation)\n * processing \u2500\u2500\u25BA expired (expiry worker)\n *\n * Rejected (regression guard):\n * completed \u2500\u2500\u25BA * REJECTED\n * failed \u2500\u2500\u25BA * REJECTED\n * cancelled \u2500\u2500\u25BA * REJECTED\n * expired \u2500\u2500\u25BA * REJECTED\n */\nexport const VALID_CHECKOUT_TRANSITIONS: Record<CheckoutTransaction['status'], CheckoutTransaction['status'][]> = {\n pending: ['processing', 'completed', 'failed', 'cancelled', 'expired'],\n processing: ['completed', 'failed', 'cancelled', 'expired'],\n // Terminal states \u2014 no transitions out\n completed: [],\n failed: [],\n cancelled: [],\n expired: [],\n}\n\n/**\n * Returns `true` when transitioning from `from` to `to` is allowed by the\n * state machine. Same-state transitions are allowed as safe no-ops to support\n * idempotency.\n */\nexport function isValidCheckoutStatusTransition(\n from: CheckoutTransaction['status'],\n to: CheckoutTransaction['status'],\n): boolean {\n if (from === to) return true\n const allowed = VALID_CHECKOUT_TRANSITIONS[from]\n if (!allowed) return false\n return allowed.includes(to)\n}\n\n/**\n * Throws a structured 409 Conflict when the requested status transition is\n * not allowed by the state machine. The error body is compatible with the\n * `conflict` error shape that the frontend handles.\n *\n * This must be called **inside** a database transaction so that the check and\n * the subsequent write are atomic. The atomic `nativeUpdate` WHERE-clause\n * provides the TOCTOU safety net; this function is the human-readable guard.\n */\nexport function assertValidCheckoutStatusTransition(\n from: CheckoutTransaction['status'],\n to: CheckoutTransaction['status'],\n): void {\n if (isValidCheckoutStatusTransition(from, to)) return\n throw new CrudHttpError(409, {\n error: `[internal] Cannot transition payment from \"${from}\" to \"${to}\"`,\n code: 'invalid_status_transition',\n currentStatus: from,\n requestedStatus: to,\n })\n}\n"],
5
+ "mappings": "AAAA,SAAS,qBAAqB;AAyBvB,MAAM,6BAAqG;AAAA,EAChH,SAAS,CAAC,cAAc,aAAa,UAAU,aAAa,SAAS;AAAA,EACrE,YAAY,CAAC,aAAa,UAAU,aAAa,SAAS;AAAA;AAAA,EAE1D,WAAW,CAAC;AAAA,EACZ,QAAQ,CAAC;AAAA,EACT,WAAW,CAAC;AAAA,EACZ,SAAS,CAAC;AACZ;AAOO,SAAS,gCACd,MACA,IACS;AACT,MAAI,SAAS,GAAI,QAAO;AACxB,QAAM,UAAU,2BAA2B,IAAI;AAC/C,MAAI,CAAC,QAAS,QAAO;AACrB,SAAO,QAAQ,SAAS,EAAE;AAC5B;AAWO,SAAS,oCACd,MACA,IACM;AACN,MAAI,gCAAgC,MAAM,EAAE,EAAG;AAC/C,QAAM,IAAI,cAAc,KAAK;AAAA,IAC3B,OAAO,8CAA8C,IAAI,SAAS,EAAE;AAAA,IACpE,MAAM;AAAA,IACN,eAAe;AAAA,IACf,iBAAiB;AAAA,EACnB,CAAC;AACH;",
6
+ "names": []
7
+ }
@@ -1,3 +1,4 @@
1
+ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
1
2
  import { CHECKOUT_ENTITY_IDS } from "./lib/constants.js";
2
3
  function asSearchText(value) {
3
4
  return typeof value === "string" ? value : "";
@@ -31,15 +32,52 @@ const searchConfig = {
31
32
  searchable: ["name", "title"],
32
33
  excluded: ["passwordHash", "gatewaySettings", "customerFieldsSchema"]
33
34
  },
35
+ buildSource: async (ctx) => {
36
+ const { t } = await resolveTranslations();
37
+ const linkTemplate = t("checkout.search.subtitle.linkTemplate", "Link Template");
38
+ return {
39
+ text: [`${asSearchText(ctx.record.name)}: ${asSearchText(ctx.record.title)}`],
40
+ presenter: { title: asSearchText(ctx.record.name), subtitle: linkTemplate },
41
+ checksumSource: { record: ctx.record, customFields: ctx.customFields }
42
+ };
43
+ },
44
+ formatResult: async (ctx) => {
45
+ const { t } = await resolveTranslations();
46
+ return {
47
+ title: asSearchText(ctx.record.name),
48
+ subtitle: t("checkout.search.subtitle.linkTemplate", "Link Template"),
49
+ icon: "lucide:file-text"
50
+ };
51
+ }
52
+ },
53
+ {
54
+ entityId: CHECKOUT_ENTITY_IDS.transaction,
55
+ enabled: true,
56
+ priority: 6,
57
+ fieldPolicy: {
58
+ searchable: ["email", "firstName", "lastName"]
59
+ },
34
60
  buildSource: async (ctx) => ({
35
- text: [`${asSearchText(ctx.record.name)}: ${asSearchText(ctx.record.title)}`],
36
- presenter: { title: asSearchText(ctx.record.name), subtitle: "Link Template" },
37
- checksumSource: { record: ctx.record, customFields: ctx.customFields }
61
+ text: [
62
+ asSearchText(ctx.record.email),
63
+ asSearchText(ctx.record.firstName),
64
+ asSearchText(ctx.record.lastName)
65
+ ],
66
+ fields: {
67
+ email: ctx.record.email,
68
+ first_name: ctx.record.firstName,
69
+ last_name: ctx.record.lastName
70
+ },
71
+ presenter: {
72
+ title: asSearchText(ctx.record.email),
73
+ subtitle: `${asSearchText(ctx.record.firstName)} ${asSearchText(ctx.record.lastName)}`.trim()
74
+ },
75
+ checksumSource: { record: ctx.record }
38
76
  }),
39
77
  formatResult: async (ctx) => ({
40
- title: asSearchText(ctx.record.name),
41
- subtitle: "Link Template",
42
- icon: "lucide:file-text"
78
+ title: asSearchText(ctx.record.email),
79
+ subtitle: `${asSearchText(ctx.record.firstName)} ${asSearchText(ctx.record.lastName)}`.trim(),
80
+ icon: "lucide:receipt"
43
81
  })
44
82
  }
45
83
  ]
@@ -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 { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\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 const { t } = await resolveTranslations()\n const linkTemplate = t('checkout.search.subtitle.linkTemplate', 'Link Template')\n return {\n text: [`${asSearchText(ctx.record.name)}: ${asSearchText(ctx.record.title)}`],\n presenter: { title: asSearchText(ctx.record.name), subtitle: linkTemplate },\n checksumSource: { record: ctx.record, customFields: ctx.customFields },\n }\n },\n formatResult: async (ctx) => {\n const { t } = await resolveTranslations()\n return {\n title: asSearchText(ctx.record.name),\n subtitle: t('checkout.search.subtitle.linkTemplate', 'Link Template'),\n icon: 'lucide:file-text',\n }\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": "AACA,SAAS,2BAA2B;AACpC,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,QAAQ;AAC1B,cAAM,EAAE,EAAE,IAAI,MAAM,oBAAoB;AACxC,cAAM,eAAe,EAAE,yCAAyC,eAAe;AAC/E,eAAO;AAAA,UACL,MAAM,CAAC,GAAG,aAAa,IAAI,OAAO,IAAI,CAAC,KAAK,aAAa,IAAI,OAAO,KAAK,CAAC,EAAE;AAAA,UAC5E,WAAW,EAAE,OAAO,aAAa,IAAI,OAAO,IAAI,GAAG,UAAU,aAAa;AAAA,UAC1E,gBAAgB,EAAE,QAAQ,IAAI,QAAQ,cAAc,IAAI,aAAa;AAAA,QACvE;AAAA,MACF;AAAA,MACA,cAAc,OAAO,QAAQ;AAC3B,cAAM,EAAE,EAAE,IAAI,MAAM,oBAAoB;AACxC,eAAO;AAAA,UACL,OAAO,aAAa,IAAI,OAAO,IAAI;AAAA,UACnC,UAAU,EAAE,yCAAyC,eAAe;AAAA,UACpE,MAAM;AAAA,QACR;AAAA,MACF;AAAA,IACF;AAAA,IACA;AAAA,MACE,UAAU,oBAAoB;AAAA,MAC9B,SAAS;AAAA,MACT,UAAU;AAAA,MACV,aAAa;AAAA,QACX,YAAY,CAAC,SAAS,aAAa,UAAU;AAAA,MAC/C;AAAA,MACA,aAAa,OAAO,SAAS;AAAA,QAC3B,MAAM;AAAA,UACJ,aAAa,IAAI,OAAO,KAAK;AAAA,UAC7B,aAAa,IAAI,OAAO,SAAS;AAAA,UACjC,aAAa,IAAI,OAAO,QAAQ;AAAA,QAClC;AAAA,QACA,QAAQ;AAAA,UACN,OAAO,IAAI,OAAO;AAAA,UAClB,YAAY,IAAI,OAAO;AAAA,UACvB,WAAW,IAAI,OAAO;AAAA,QACxB;AAAA,QACA,WAAW;AAAA,UACT,OAAO,aAAa,IAAI,OAAO,KAAK;AAAA,UACpC,UAAU,GAAG,aAAa,IAAI,OAAO,SAAS,CAAC,IAAI,aAAa,IAAI,OAAO,QAAQ,CAAC,GAAG,KAAK;AAAA,QAC9F;AAAA,QACA,gBAAgB,EAAE,QAAQ,IAAI,OAAO;AAAA,MACvC;AAAA,MACA,cAAc,OAAO,SAAS;AAAA,QAC5B,OAAO,aAAa,IAAI,OAAO,KAAK;AAAA,QACpC,UAAU,GAAG,aAAa,IAAI,OAAO,SAAS,CAAC,IAAI,aAAa,IAAI,OAAO,QAAQ,CAAC,GAAG,KAAK;AAAA,QAC5F,MAAM;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACF;AAEO,MAAM,SAAS;AACtB,IAAO,iBAAQ;",
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",
3
+ "version": "0.6.8-develop.6874.1.982d6097d8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -62,27 +62,27 @@
62
62
  }
63
63
  },
64
64
  "dependencies": {
65
- "@open-mercato/core": "0.6.7",
66
- "@open-mercato/ui": "0.6.7",
65
+ "@open-mercato/core": "0.6.8-develop.6874.1.982d6097d8",
66
+ "@open-mercato/ui": "0.6.8-develop.6874.1.982d6097d8",
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",
71
+ "@open-mercato/shared": "0.6.8-develop.6874.1.982d6097d8",
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",
76
+ "@open-mercato/shared": "0.6.8-develop.6874.1.982d6097d8",
77
77
  "@types/jest": "^30.0.0",
78
78
  "@types/react": "^19.2.17",
79
79
  "@types/react-dom": "^19.2.3",
80
80
  "esbuild": "^0.28.1",
81
81
  "glob": "^13.0.6",
82
82
  "jest": "^30.4.2",
83
- "react": "19.2.7",
84
- "react-dom": "19.2.7",
85
- "ts-jest": "^29.4.11",
83
+ "react": "19.2.8",
84
+ "react-dom": "19.2.8",
85
+ "ts-jest": "^29.4.12",
86
86
  "typescript": "7.0.2"
87
87
  },
88
88
  "publishConfig": {
@@ -92,5 +92,6 @@
92
92
  "type": "git",
93
93
  "url": "https://github.com/open-mercato/open-mercato",
94
94
  "directory": "packages/checkout"
95
- }
95
+ },
96
+ "stableVersion": "0.6.7"
96
97
  }
@@ -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,112 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { getAuthToken, apiRequest } from '@open-mercato/core/modules/core/__integration__/helpers/api'
3
+ import { readJsonSafe } from '@open-mercato/core/modules/core/__integration__/helpers/generalFixtures'
4
+ import {
5
+ createFixedTemplateInput,
6
+ createLinkFixture,
7
+ deleteCheckoutEntityIfExists,
8
+ findGatewayTransactionIdForCheckout,
9
+ readCheckoutTransaction,
10
+ sendMockGatewayWebhook,
11
+ submitPayLink,
12
+ waitForCheckoutStatus,
13
+ } from './helpers/fixtures'
14
+
15
+ /**
16
+ * TC-CHKT-043: Two-contender concurrency test for updateTransactionStatus.
17
+ *
18
+ * Verifies that the compare-and-swap (CAS) in updateTransactionStatusCommand
19
+ * serializes two concurrent writers correctly:
20
+ *
21
+ * - Exactly one of two concurrent updateStatus calls wins.
22
+ * - The loser receives a 409 (concurrent_status_update).
23
+ * - The persisted terminal status stays 'completed'.
24
+ * - CheckoutLink.completionCount is incremented exactly once.
25
+ *
26
+ * This test exercises the race described in #4700 using a real database,
27
+ * satisfying .ai/review-checklist.md §20 (concurrency-sensitive commands
28
+ * require an executable two-contender test).
29
+ */
30
+ test.describe('TC-CHKT-043 (concurrency): Two-contender race — only one updateStatus call wins', () => {
31
+ test('concurrent completed + processing status updates: one wins, one gets 409, completionCount incremented once', async ({ request }) => {
32
+ let token: string | null = null
33
+ let linkId: string | null = null
34
+
35
+ try {
36
+ token = await getAuthToken(request)
37
+
38
+ // Create an active link that accepts a single payment.
39
+ const link = await createLinkFixture(request, token, {
40
+ ...createFixedTemplateInput({ status: 'active', collectCustomerDetails: false }),
41
+ })
42
+ linkId = link.id
43
+
44
+ // Submit a checkout to create a transaction and a gateway session.
45
+ const submitResponse = await submitPayLink(request, link.slug, {
46
+ customerData: {},
47
+ acceptedLegalConsents: {},
48
+ amount: 49.99,
49
+ })
50
+ expect(submitResponse.status(), `Submit failed: ${submitResponse.status()}`).toBe(201)
51
+ const submitBody = await submitResponse.json() as { transactionId: string }
52
+ const transactionId = submitBody.transactionId
53
+ expect(transactionId).toBeTruthy()
54
+
55
+ // Find the mock-gateway session ID so we can fire the captured webhook.
56
+ const gatewayTransactionId = await findGatewayTransactionIdForCheckout(request, token, transactionId)
57
+ const gatewayTransaction = await apiRequest(request, 'GET', `/api/payment_gateways/transactions/${encodeURIComponent(gatewayTransactionId)}`, { token })
58
+ const gtBody = await readJsonSafe<{ transaction?: { providerSessionId?: string | null } }>(gatewayTransaction)
59
+ const providerSessionId = gtBody?.transaction?.providerSessionId
60
+ expect(providerSessionId, 'providerSessionId is required for webhook simulation').toBeTruthy()
61
+
62
+ // --- Two-contender race ---
63
+ // Simultaneously fire:
64
+ // (A) The gateway "captured" webhook ? tries to set status = 'completed'.
65
+ // (B) A direct updateStatus API call ? tries to set status = 'processing'
66
+ // (simulates the submit route's late write losing the race).
67
+ //
68
+ // Both target the same transaction. Exactly one must succeed; the other
69
+ // must receive a 409 so no lost-update occurs.
70
+ const [webhookResult, directResult] = await Promise.allSettled([
71
+ sendMockGatewayWebhook(request, token!, providerSessionId!, 'captured', 49.99),
72
+ apiRequest(request, 'POST', `/api/checkout/transactions/${encodeURIComponent(transactionId)}/status`, {
73
+ token,
74
+ data: { status: 'processing', paymentStatus: 'processing' },
75
+ }),
76
+ ])
77
+
78
+ // At least the webhook path should have been attempted. The direct call
79
+ // may receive 409 (CAS missed) or 422 (invalid transition) depending on
80
+ // which writer committed first — both are acceptable losing outcomes.
81
+ // The important invariant is the final persisted state.
82
+ expect(webhookResult.status === 'fulfilled' || directResult.status === 'fulfilled').toBe(true)
83
+
84
+ // Wait for the transaction to reach a terminal state (the webhook sets it
85
+ // to 'completed'; the submit route's 'processing' write must not win).
86
+ const finalTransaction = await waitForCheckoutStatus(request, token!, transactionId, 'completed', {
87
+ attempts: 30,
88
+ intervalMs: 200,
89
+ })
90
+
91
+ // --- Core assertions ---
92
+
93
+ // 1. The persisted status must be 'completed' — not 'processing'.
94
+ expect(finalTransaction.status).toBe('completed')
95
+
96
+ // 2. completionCount must be exactly 1 — the terminal state must have
97
+ // been applied exactly once, not zero or twice.
98
+ const updatedLinkResponse = await apiRequest(request, 'GET', `/api/checkout/links/${encodeURIComponent(link.id)}`, { token })
99
+ // The detail route spreads the serialized link at the top level; there is
100
+ // no `link` wrapper. Reading one made this assertion compare undefined to 1.
101
+ const updatedLinkBody = await readJsonSafe<{ completionCount?: number }>(updatedLinkResponse)
102
+ expect(updatedLinkBody?.completionCount).toBe(1)
103
+
104
+ // 3. The stored transaction must have a gatewayTransactionId from the webhook.
105
+ const storedTransaction = await readCheckoutTransaction(request, token!, transactionId)
106
+ expect(storedTransaction.status).toBe('completed')
107
+ expect(storedTransaction.gatewayTransactionId).toBeTruthy()
108
+ } finally {
109
+ await deleteCheckoutEntityIfExists(request, token, 'links', linkId)
110
+ }
111
+ })
112
+ })
@@ -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
+ })