@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,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
+ })
@@ -1,4 +1,5 @@
1
1
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
2
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
2
3
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
4
  import { checkRateLimit, getClientIp } from '@open-mercato/shared/lib/ratelimit/helpers'
4
5
  import { POST } from '../route'
@@ -298,4 +299,80 @@ describe('POST /api/checkout/pay/[slug]/submit', () => {
298
299
  const payload = await response.json()
299
300
  expect(payload.error).toBe('Invalid request host')
300
301
  })
302
+
303
+ it('returns 201 when updateStatus rejects with concurrent_status_update (webhook won the race, payment succeeded)', async () => {
304
+ // The webhook already advanced the transaction to 'completed' before the
305
+ // submit route's updateStatus call — that 409 must be treated as benign
306
+ // and the route must still return 201 with the real terminal transaction.
307
+ const terminalTransaction = createTransaction({ status: 'completed', gatewayTransactionId: GATEWAY_TRANSACTION_ID })
308
+ ;(findOneWithDecryption as jest.Mock)
309
+ .mockResolvedValueOnce(createLink()) // link lookup
310
+ .mockResolvedValueOnce(null) // no existing transaction (idempotency pre-check)
311
+ .mockResolvedValueOnce(createTransaction()) // transaction after create
312
+ .mockResolvedValueOnce(terminalTransaction) // refreshed transaction after conflict
313
+
314
+ const conflictError = new CrudHttpError(409, {
315
+ code: 'concurrent_status_update',
316
+ currentStatus: 'completed',
317
+ expectedStatus: 'processing',
318
+ requestedStatus: 'processing',
319
+ error: '[internal] concurrent update',
320
+ })
321
+ mockCommandExecute
322
+ .mockResolvedValueOnce({ result: { id: TRANSACTION_ID } }) // create
323
+ .mockRejectedValueOnce(conflictError) // updateStatus -> conflict
324
+
325
+ const response = await POST(
326
+ new Request('https://merchant.example/api/checkout/pay/donate/submit', {
327
+ method: 'POST',
328
+ headers: {
329
+ 'content-type': 'application/json',
330
+ 'Idempotency-Key': 'race-conflict-key-123',
331
+ origin: 'https://merchant.example',
332
+ },
333
+ body: JSON.stringify({ customerData: {}, acceptedLegalConsents: {}, amount: 1 }),
334
+ }),
335
+ { params: { slug: 'donate' } },
336
+ )
337
+
338
+ expect(response.status).toBe(201)
339
+ const payload = await response.json()
340
+ expect(payload.transactionId).toBe(TRANSACTION_ID)
341
+ })
342
+
343
+ it('returns 201 when updateStatus rejects with invalid_status_transition (terminal state already set)', async () => {
344
+ const terminalTransaction = createTransaction({ status: 'completed', gatewayTransactionId: GATEWAY_TRANSACTION_ID })
345
+ ;(findOneWithDecryption as jest.Mock)
346
+ .mockResolvedValueOnce(createLink())
347
+ .mockResolvedValueOnce(null)
348
+ .mockResolvedValueOnce(createTransaction())
349
+ .mockResolvedValueOnce(terminalTransaction)
350
+
351
+ const invalidTransitionError = new CrudHttpError(409, {
352
+ code: 'invalid_status_transition',
353
+ currentStatus: 'completed',
354
+ requestedStatus: 'processing',
355
+ error: '[internal] invalid transition',
356
+ })
357
+ mockCommandExecute
358
+ .mockResolvedValueOnce({ result: { id: TRANSACTION_ID } })
359
+ .mockRejectedValueOnce(invalidTransitionError)
360
+
361
+ const response = await POST(
362
+ new Request('https://merchant.example/api/checkout/pay/donate/submit', {
363
+ method: 'POST',
364
+ headers: {
365
+ 'content-type': 'application/json',
366
+ 'Idempotency-Key': 'invalid-transition-key1',
367
+ origin: 'https://merchant.example',
368
+ },
369
+ body: JSON.stringify({ customerData: {}, acceptedLegalConsents: {}, amount: 1 }),
370
+ }),
371
+ { params: { slug: 'donate' } },
372
+ )
373
+
374
+ expect(response.status).toBe(201)
375
+ const payload = await response.json()
376
+ expect(payload.transactionId).toBe(TRANSACTION_ID)
377
+ })
301
378
  })
@@ -3,7 +3,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
3
3
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
4
  import type { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'
5
5
  import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
6
- import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
6
+ import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
7
7
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
8
8
  import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
9
9
  import { checkRateLimit } from '@open-mercato/shared/lib/ratelimit/helpers'
@@ -472,8 +472,10 @@ export async function POST(req: Request, { params }: { params: Promise<{ slug: s
472
472
  || link.gatewaySettings?.presentationMode === 'auto'
473
473
  ? link.gatewaySettings.presentationMode
474
474
  : undefined
475
+
476
+ let sessionResult: Awaited<ReturnType<typeof paymentGatewayService.createPaymentSession>>
475
477
  try {
476
- const sessionResult = await paymentGatewayService.createPaymentSession({
478
+ sessionResult = await paymentGatewayService.createPaymentSession({
477
479
  providerKey: link.gatewayProviderKey,
478
480
  paymentId: transactionId,
479
481
  idempotencyKey,
@@ -497,18 +499,8 @@ export async function POST(req: Request, { params }: { params: Promise<{ slug: s
497
499
  organizationId: link.organizationId,
498
500
  tenantId: link.tenantId,
499
501
  })
500
- await commandBus.execute('checkout.transaction.updateStatus', {
501
- input: {
502
- id: transaction.id,
503
- status: mapGatewayStatusToCheckoutStatus(sessionResult.transaction.unifiedStatus),
504
- paymentStatus: sessionResult.transaction.unifiedStatus,
505
- gatewayTransactionId: sessionResult.transaction.id,
506
- organizationId: link.organizationId,
507
- tenantId: link.tenantId,
508
- },
509
- ctx,
510
- })
511
502
  } catch (error) {
503
+ // The gateway itself failed — mark the transaction failed and surface 502.
512
504
  await commandBus.execute('checkout.transaction.updateStatus', {
513
505
  input: {
514
506
  id: transaction.id,
@@ -527,6 +519,41 @@ export async function POST(req: Request, { params }: { params: Promise<{ slug: s
527
519
  })
528
520
  throw new CrudHttpError(502, { error: 'checkout.payPage.errors.sessionStart' })
529
521
  }
522
+
523
+ // Update the transaction status to reflect the gateway response.
524
+ // If the webhook already raced ahead and completed this transaction,
525
+ // updateStatus will throw a 409 conflict — that is correct behavior and
526
+ // means the payment succeeded, so we treat it as benign and fall through
527
+ // to return the real, already-terminal transaction below.
528
+ let statusConflict = false
529
+ try {
530
+ await commandBus.execute('checkout.transaction.updateStatus', {
531
+ input: {
532
+ id: transaction.id,
533
+ status: mapGatewayStatusToCheckoutStatus(sessionResult.transaction.unifiedStatus),
534
+ paymentStatus: sessionResult.transaction.unifiedStatus,
535
+ gatewayTransactionId: sessionResult.transaction.id,
536
+ organizationId: link.organizationId,
537
+ tenantId: link.tenantId,
538
+ },
539
+ ctx,
540
+ })
541
+ } catch (error) {
542
+ const isConflict = isCrudHttpError(error)
543
+ && error.status === 409
544
+ && (
545
+ (error.body as Record<string, unknown> | undefined)?.code === 'concurrent_status_update'
546
+ || (error.body as Record<string, unknown> | undefined)?.code === 'invalid_status_transition'
547
+ )
548
+ if (!isConflict) throw error
549
+ logger.info('Transaction status already advanced by concurrent writer — webhook won the race', {
550
+ linkId: link.id,
551
+ transactionId: transaction.id,
552
+ conflictCode: (error.body as Record<string, unknown> | undefined)?.code,
553
+ })
554
+ statusConflict = true
555
+ }
556
+
530
557
  const refreshedTransaction = await findOneWithDecryption(em, CheckoutTransaction, {
531
558
  id: transaction.id,
532
559
  organizationId: link.organizationId,
@@ -535,26 +562,29 @@ export async function POST(req: Request, { params }: { params: Promise<{ slug: s
535
562
  if (!refreshedTransaction) {
536
563
  throw new CrudHttpError(404, { error: 'Transaction not found' })
537
564
  }
538
- await emitCheckoutEvent('checkout.transaction.sessionStarted', {
539
- transactionId: refreshedTransaction.id,
540
- linkId: refreshedTransaction.linkId,
541
- templateId: link.templateId ?? null,
542
- slug: link.slug,
543
- status: refreshedTransaction.status,
544
- paymentStatus: refreshedTransaction.paymentStatus ?? null,
545
- amount: Number(refreshedTransaction.amount),
546
- currency: refreshedTransaction.currencyCode,
547
- gatewayProvider: link.gatewayProviderKey,
548
- gatewayTransactionId: refreshedTransaction.gatewayTransactionId ?? null,
549
- occurredAt: new Date().toISOString(),
550
- tenantId: link.tenantId,
551
- organizationId: link.organizationId,
552
- }).catch(() => undefined)
565
+ if (!statusConflict) {
566
+ await emitCheckoutEvent('checkout.transaction.sessionStarted', {
567
+ transactionId: refreshedTransaction.id,
568
+ linkId: refreshedTransaction.linkId,
569
+ templateId: link.templateId ?? null,
570
+ slug: link.slug,
571
+ status: refreshedTransaction.status,
572
+ paymentStatus: refreshedTransaction.paymentStatus ?? null,
573
+ amount: Number(refreshedTransaction.amount),
574
+ currency: refreshedTransaction.currencyCode,
575
+ gatewayProvider: link.gatewayProviderKey,
576
+ gatewayTransactionId: refreshedTransaction.gatewayTransactionId ?? null,
577
+ occurredAt: new Date().toISOString(),
578
+ tenantId: link.tenantId,
579
+ organizationId: link.organizationId,
580
+ }).catch(() => undefined)
581
+ }
553
582
  return NextResponse.json(
554
583
  await buildSubmitResponse(req, em, link, refreshedTransaction, link.gatewayProviderKey),
555
584
  { status: 201 },
556
585
  )
557
586
  }
587
+
558
588
  return NextResponse.json(await buildSubmitResponse(req, em, link, transaction, link.gatewayProviderKey), { status: 201 })
559
589
  } catch (error) {
560
590
  return handleCheckoutRouteError(error)
@@ -0,0 +1,113 @@
1
+ /** @jest-environment node */
2
+
3
+ import { GET } from '../route'
4
+ import { CheckoutTransaction } from '../../../data/entities'
5
+
6
+ const mockFindAndCountWithDecryption = jest.fn()
7
+ const mockFindWithDecryption = jest.fn()
8
+
9
+ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
10
+ findAndCountWithDecryption: (...args: unknown[]) => mockFindAndCountWithDecryption(...args),
11
+ findWithDecryption: (...args: unknown[]) => mockFindWithDecryption(...args),
12
+ }))
13
+
14
+ const mockFindEntityIdsBySearchTokens = jest.fn(async () => ({ matched: false, reason: 'no-tokens' as const }))
15
+
16
+ jest.mock('@open-mercato/shared/lib/search/tokenLookup', () => ({
17
+ findEntityIdsBySearchTokens: (...args: unknown[]) => mockFindEntityIdsBySearchTokens(...(args as [])),
18
+ }))
19
+
20
+ const mockEm = { getKysely: jest.fn(() => ({})) }
21
+ const auth = { tenantId: 'tenant-1', orgId: 'org-1', sub: 'user-1' }
22
+
23
+ jest.mock('../../helpers', () => ({
24
+ requireAdminContext: jest.fn(async () => ({ auth, container: {}, em: mockEm, commandBus: {} })),
25
+ userHasCheckoutFeature: jest.fn(async () => true),
26
+ handleCheckoutRouteError: (error: unknown) => {
27
+ throw error
28
+ },
29
+ }))
30
+
31
+ function makeRequest(params?: Record<string, string>) {
32
+ const url = new URL('http://localhost/api/checkout/transactions')
33
+ for (const [key, value] of Object.entries(params ?? {})) url.searchParams.set(key, value)
34
+ return new Request(url.toString(), { method: 'GET' })
35
+ }
36
+
37
+ function capturedWhere(): Record<string, unknown> {
38
+ return mockFindAndCountWithDecryption.mock.calls[0][2] as Record<string, unknown>
39
+ }
40
+
41
+ describe('GET /api/checkout/transactions', () => {
42
+ beforeEach(() => {
43
+ jest.clearAllMocks()
44
+ mockFindAndCountWithDecryption.mockResolvedValue([[], 0])
45
+ mockFindWithDecryption.mockResolvedValue([])
46
+ mockFindEntityIdsBySearchTokens.mockResolvedValue({ matched: false, reason: 'no-tokens' } as never)
47
+ })
48
+
49
+ it('applies no search predicate when no term is supplied', async () => {
50
+ await GET(makeRequest())
51
+
52
+ expect(mockFindAndCountWithDecryption).toHaveBeenCalledWith(
53
+ mockEm,
54
+ CheckoutTransaction,
55
+ { organizationId: 'org-1', tenantId: 'tenant-1' },
56
+ expect.any(Object),
57
+ expect.any(Object),
58
+ )
59
+ expect(mockFindEntityIdsBySearchTokens).not.toHaveBeenCalled()
60
+ })
61
+
62
+ it('keeps the escaped ILIKE predicates for the PII columns', async () => {
63
+ await GET(makeRequest({ search: '100%_off' }))
64
+
65
+ expect(capturedWhere().$or).toEqual([
66
+ { email: { $ilike: '%100\\%\\_off%' } },
67
+ { firstName: { $ilike: '%100\\%\\_off%' } },
68
+ { lastName: { $ilike: '%100\\%\\_off%' } },
69
+ ])
70
+ })
71
+
72
+ it('unions the encrypted-column token matches into the search predicate', async () => {
73
+ mockFindEntityIdsBySearchTokens.mockResolvedValueOnce({
74
+ matched: true,
75
+ ids: ['tx-1', 'tx-2'],
76
+ } as never)
77
+
78
+ await GET(makeRequest({ search: 'ada@example.com' }))
79
+
80
+ expect(mockFindEntityIdsBySearchTokens).toHaveBeenCalledWith(expect.objectContaining({
81
+ entityType: 'checkout:checkout_transaction',
82
+ query: 'ada@example.com',
83
+ fields: ['email', 'first_name', 'last_name'],
84
+ scope: { tenantId: 'tenant-1', organizationId: 'org-1' },
85
+ }))
86
+ expect(capturedWhere().$or).toContainEqual({ id: { $in: ['tx-1', 'tx-2'] } })
87
+ })
88
+
89
+ it('never emits an ILIKE against the uuid id column', async () => {
90
+ await GET(makeRequest({ search: 'abc' }))
91
+
92
+ const or = capturedWhere().$or as Array<Record<string, unknown>>
93
+ expect(or.some((clause) => 'id' in clause)).toBe(false)
94
+ })
95
+
96
+ it('matches a uuid search term against the id column exactly', async () => {
97
+ const id = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'
98
+ await GET(makeRequest({ search: id }))
99
+
100
+ expect(capturedWhere().$or).toContainEqual({ id })
101
+ })
102
+
103
+ it('keeps link and status filters alongside the search predicate', async () => {
104
+ await GET(makeRequest({ search: 'ada', status: 'completed', linkId: 'link-1' }))
105
+
106
+ expect(capturedWhere()).toMatchObject({
107
+ organizationId: 'org-1',
108
+ tenantId: 'tenant-1',
109
+ status: 'completed',
110
+ linkId: 'link-1',
111
+ })
112
+ })
113
+ })
@@ -1,11 +1,15 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { findAndCountWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
3
  import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
4
+ import { findEntityIdsBySearchTokens, type SearchTokenDatabase } from '@open-mercato/shared/lib/search/tokenLookup'
4
5
  import { CheckoutLink, CheckoutTransaction } from '../../data/entities'
6
+ import { CHECKOUT_ENTITY_IDS } from '../../lib/constants'
5
7
  import { handleCheckoutRouteError, requireAdminContext, userHasCheckoutFeature } from '../helpers'
6
8
  import { checkoutTag } from '../openapi'
7
9
  import { serializeTransaction } from '../../lib/utils'
8
10
 
11
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
12
+
9
13
  export const metadata = {
10
14
  path: '/checkout/transactions',
11
15
  GET: { requireAuth: true, requireFeatures: ['checkout.view'] },
@@ -27,12 +31,32 @@ export async function GET(req: Request) {
27
31
  }
28
32
  if (linkId) where.linkId = linkId
29
33
  if (status) where.status = status
30
- if (search) where.$or = [
31
- { email: { $ilike: `%${escapeLikePattern(search)}%` } },
32
- { firstName: { $ilike: `%${escapeLikePattern(search)}%` } },
33
- { lastName: { $ilike: `%${escapeLikePattern(search)}%` } },
34
- { id: { $ilike: `%${escapeLikePattern(search)}%` } },
35
- ]
34
+ if (search) {
35
+ const pattern = `%${escapeLikePattern(search)}%`
36
+ // email/first_name/last_name are covered by the checkout encryption map,
37
+ // so an ILIKE alone compares the pattern against ciphertext and matches
38
+ // nothing once encryption is on. Union it with the token index, which
39
+ // stores hashes of the plaintext. Issue #2990.
40
+ const searchOr: Record<string, unknown>[] = [
41
+ { email: { $ilike: pattern } },
42
+ { firstName: { $ilike: pattern } },
43
+ { lastName: { $ilike: pattern } },
44
+ ]
45
+ // `id` is a uuid column: ILIKE against it has no Postgres operator and
46
+ // raises 42883, so only an exact id lookup is meaningful here.
47
+ if (UUID_PATTERN.test(search)) searchOr.push({ id: search })
48
+ const tokenMatch = await findEntityIdsBySearchTokens({
49
+ db: em.getKysely<SearchTokenDatabase>(),
50
+ entityType: CHECKOUT_ENTITY_IDS.transaction,
51
+ query: search,
52
+ fields: ['email', 'first_name', 'last_name'],
53
+ scope: { tenantId: auth.tenantId, organizationId: auth.orgId },
54
+ })
55
+ if (tokenMatch.matched && tokenMatch.ids.length) {
56
+ searchOr.push({ id: { $in: tokenMatch.ids } })
57
+ }
58
+ where.$or = searchOr
59
+ }
36
60
  const [items, total] = await findAndCountWithDecryption(
37
61
  em,
38
62
  CheckoutTransaction,
@@ -1,5 +1,6 @@
1
1
  "use client"
2
2
  import * as React from 'react'
3
+ import { extensionPoints } from '@open-mercato/checkout/modules/checkout/extension-points'
3
4
  import Link from 'next/link'
4
5
  import { Plus } from 'lucide-react'
5
6
  import type { ColumnDef } from '@tanstack/react-table'
@@ -197,7 +198,7 @@ export default function CheckoutPayLinksPage() {
197
198
  onFiltersClear={() => { setFilters({}); setPage(1) }}
198
199
  pagination={{ page, pageSize: 25, total, totalPages, onPageChange: setPage }}
199
200
  isLoading={loading}
200
- perspective={{ tableId: 'checkout-links' }}
201
+ perspective={{ tableId: extensionPoints.hosts.linksTable.tableId }}
201
202
  actions={(
202
203
  <Button asChild>
203
204
  <Link href="/backend/checkout/pay-links/create">
@@ -1,5 +1,6 @@
1
1
  "use client"
2
2
  import * as React from 'react'
3
+ import { extensionPoints } from '@open-mercato/checkout/modules/checkout/extension-points'
3
4
  import Link from 'next/link'
4
5
  import { Plus } from 'lucide-react'
5
6
  import type { ColumnDef } from '@tanstack/react-table'
@@ -121,7 +122,7 @@ export default function CheckoutTemplatesPage() {
121
122
  onFiltersApply={(next) => { setFilters(next); setPage(1) }}
122
123
  onFiltersClear={() => { setFilters({}); setPage(1) }}
123
124
  pagination={{ page, pageSize: 25, total, totalPages, onPageChange: setPage }}
124
- perspective={{ tableId: 'checkout-templates' }}
125
+ perspective={{ tableId: extensionPoints.hosts.templatesTable.tableId }}
125
126
  actions={(
126
127
  <Button asChild>
127
128
  <Link href="/backend/checkout/templates/create">
@@ -1,5 +1,6 @@
1
1
  "use client"
2
2
  import * as React from 'react'
3
+ import { extensionPoints } from '@open-mercato/checkout/modules/checkout/extension-points'
3
4
  import { useSearchParams } from 'next/navigation'
4
5
  import type { ColumnDef } from '@tanstack/react-table'
5
6
  import { useT } from '@open-mercato/shared/lib/i18n/context'
@@ -237,7 +238,7 @@ export default function CheckoutTransactionsPage() {
237
238
  onFiltersApply={(next) => { setFilters(next); setPage(1) }}
238
239
  onFiltersClear={() => { setFilters({}); setPage(1) }}
239
240
  pagination={{ page, pageSize: 25, total, totalPages, onPageChange: setPage }}
240
- perspective={{ tableId: 'checkout-transactions' }}
241
+ perspective={{ tableId: extensionPoints.hosts.transactionsTable.tableId }}
241
242
  rowClickActionIds={['view']}
242
243
  rowActions={(row) => <RowActions items={[{ id: 'view', label: t('checkout.admin.transactions.actions.viewDetail'), href: `/backend/checkout/transactions/${encodeURIComponent(row.id)}` }]} />}
243
244
  />