@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.
- 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/__integration__/TC-CHKT-043-concurrent-status-update.spec.js +62 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-043-concurrent-status-update.spec.js.map +7 -0
- package/dist/modules/checkout/api/pay/[slug]/submit/route.js +43 -28
- package/dist/modules/checkout/api/pay/[slug]/submit/route.js.map +2 -2
- 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/backend/checkout/pay-links/page.js +2 -1
- package/dist/modules/checkout/backend/checkout/pay-links/page.js.map +2 -2
- package/dist/modules/checkout/backend/checkout/templates/page.js +2 -1
- package/dist/modules/checkout/backend/checkout/templates/page.js.map +2 -2
- package/dist/modules/checkout/backend/checkout/transactions/page.js +2 -1
- package/dist/modules/checkout/backend/checkout/transactions/page.js.map +2 -2
- package/dist/modules/checkout/commands/links.js +1 -1
- package/dist/modules/checkout/commands/links.js.map +2 -2
- package/dist/modules/checkout/commands/transactions.js +45 -13
- package/dist/modules/checkout/commands/transactions.js.map +2 -2
- package/dist/modules/checkout/components/CustomerFieldsEditor.js +2 -2
- package/dist/modules/checkout/components/CustomerFieldsEditor.js.map +2 -2
- package/dist/modules/checkout/components/GatewaySettingsFields.js +2 -2
- package/dist/modules/checkout/components/GatewaySettingsFields.js.map +2 -2
- package/dist/modules/checkout/components/LinkTemplateForm.js +8 -8
- package/dist/modules/checkout/components/LinkTemplateForm.js.map +2 -2
- package/dist/modules/checkout/components/LogoUploadField.js +1 -1
- package/dist/modules/checkout/components/LogoUploadField.js.map +2 -2
- package/dist/modules/checkout/components/PayPage.js +39 -38
- package/dist/modules/checkout/components/PayPage.js.map +2 -2
- package/dist/modules/checkout/extension-points.js +68 -0
- package/dist/modules/checkout/extension-points.js.map +7 -0
- package/dist/modules/checkout/lib/transaction-status-machine.js +31 -0
- package/dist/modules/checkout/lib/transaction-status-machine.js.map +7 -0
- package/dist/modules/checkout/search.js +44 -6
- 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 +10 -9
- package/src/modules/checkout/__integration__/TC-CHKT-042.spec.ts +41 -0
- package/src/modules/checkout/__integration__/TC-CHKT-043-concurrent-status-update.spec.ts +112 -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/pay/[slug]/submit/__tests__/route.test.ts +77 -0
- package/src/modules/checkout/api/pay/[slug]/submit/route.ts +58 -28
- 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/backend/checkout/pay-links/page.tsx +2 -1
- package/src/modules/checkout/backend/checkout/templates/page.tsx +2 -1
- package/src/modules/checkout/backend/checkout/transactions/page.tsx +2 -1
- package/src/modules/checkout/commands/__tests__/update-transaction-status.test.ts +363 -0
- package/src/modules/checkout/commands/links.ts +5 -1
- package/src/modules/checkout/commands/transactions.ts +68 -13
- package/src/modules/checkout/components/CustomerFieldsEditor.tsx +2 -2
- package/src/modules/checkout/components/GatewaySettingsFields.tsx +2 -2
- package/src/modules/checkout/components/LinkTemplateForm.tsx +8 -8
- package/src/modules/checkout/components/LogoUploadField.tsx +1 -1
- package/src/modules/checkout/components/PayPage.tsx +38 -37
- package/src/modules/checkout/extension-points.ts +67 -0
- package/src/modules/checkout/i18n/de.json +1 -0
- package/src/modules/checkout/i18n/en.json +1 -0
- package/src/modules/checkout/i18n/es.json +1 -0
- package/src/modules/checkout/i18n/ko.json +463 -0
- package/src/modules/checkout/i18n/pl.json +1 -0
- package/src/modules/checkout/lib/__tests__/transaction-status-machine.test.ts +152 -0
- package/src/modules/checkout/lib/transaction-status-machine.ts +71 -0
- package/src/modules/checkout/search.ts +44 -6
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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)
|
|
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,
|
|
@@ -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:
|
|
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:
|
|
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:
|
|
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
|
/>
|