@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,71 @@
|
|
|
1
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
2
|
+
import type { CheckoutTransaction } from '../data/entities'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Allowed status transitions for a CheckoutTransaction.
|
|
6
|
+
*
|
|
7
|
+
* Terminal states (completed, failed, cancelled, expired) have an empty
|
|
8
|
+
* allowed-set, meaning NO transition out of them is permitted. This prevents
|
|
9
|
+
* the race condition where the submit route's late `updateStatus('processing')`
|
|
10
|
+
* call overwrites a `'completed'` status that the gateway webhook set first.
|
|
11
|
+
*
|
|
12
|
+
* Allowed path:
|
|
13
|
+
* pending ──► processing (transaction created, payment session initiated)
|
|
14
|
+
* pending ──► completed (captured webhook arrives before processing state)
|
|
15
|
+
* processing ──► completed (webhook: payment captured)
|
|
16
|
+
* processing ──► failed (webhook or session error)
|
|
17
|
+
* processing ──► cancelled (customer or merchant cancellation)
|
|
18
|
+
* processing ──► expired (expiry worker)
|
|
19
|
+
*
|
|
20
|
+
* Rejected (regression guard):
|
|
21
|
+
* completed ──► * REJECTED
|
|
22
|
+
* failed ──► * REJECTED
|
|
23
|
+
* cancelled ──► * REJECTED
|
|
24
|
+
* expired ──► * REJECTED
|
|
25
|
+
*/
|
|
26
|
+
export const VALID_CHECKOUT_TRANSITIONS: Record<CheckoutTransaction['status'], CheckoutTransaction['status'][]> = {
|
|
27
|
+
pending: ['processing', 'completed', 'failed', 'cancelled', 'expired'],
|
|
28
|
+
processing: ['completed', 'failed', 'cancelled', 'expired'],
|
|
29
|
+
// Terminal states — no transitions out
|
|
30
|
+
completed: [],
|
|
31
|
+
failed: [],
|
|
32
|
+
cancelled: [],
|
|
33
|
+
expired: [],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns `true` when transitioning from `from` to `to` is allowed by the
|
|
38
|
+
* state machine. Same-state transitions are allowed as safe no-ops to support
|
|
39
|
+
* idempotency.
|
|
40
|
+
*/
|
|
41
|
+
export function isValidCheckoutStatusTransition(
|
|
42
|
+
from: CheckoutTransaction['status'],
|
|
43
|
+
to: CheckoutTransaction['status'],
|
|
44
|
+
): boolean {
|
|
45
|
+
if (from === to) return true
|
|
46
|
+
const allowed = VALID_CHECKOUT_TRANSITIONS[from]
|
|
47
|
+
if (!allowed) return false
|
|
48
|
+
return allowed.includes(to)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Throws a structured 409 Conflict when the requested status transition is
|
|
53
|
+
* not allowed by the state machine. The error body is compatible with the
|
|
54
|
+
* `conflict` error shape that the frontend handles.
|
|
55
|
+
*
|
|
56
|
+
* This must be called **inside** a database transaction so that the check and
|
|
57
|
+
* the subsequent write are atomic. The atomic `nativeUpdate` WHERE-clause
|
|
58
|
+
* provides the TOCTOU safety net; this function is the human-readable guard.
|
|
59
|
+
*/
|
|
60
|
+
export function assertValidCheckoutStatusTransition(
|
|
61
|
+
from: CheckoutTransaction['status'],
|
|
62
|
+
to: CheckoutTransaction['status'],
|
|
63
|
+
): void {
|
|
64
|
+
if (isValidCheckoutStatusTransition(from, to)) return
|
|
65
|
+
throw new CrudHttpError(409, {
|
|
66
|
+
error: `[internal] Cannot transition payment from "${from}" to "${to}"`,
|
|
67
|
+
code: 'invalid_status_transition',
|
|
68
|
+
currentStatus: from,
|
|
69
|
+
requestedStatus: to,
|
|
70
|
+
})
|
|
71
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SearchModuleConfig } from '@open-mercato/shared/modules/search'
|
|
2
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
2
3
|
import { CHECKOUT_ENTITY_IDS } from './lib/constants'
|
|
3
4
|
|
|
4
5
|
function asSearchText(value: unknown): string {
|
|
@@ -34,15 +35,52 @@ export const searchConfig: SearchModuleConfig = {
|
|
|
34
35
|
searchable: ['name', 'title'],
|
|
35
36
|
excluded: ['passwordHash', 'gatewaySettings', 'customerFieldsSchema'],
|
|
36
37
|
},
|
|
38
|
+
buildSource: async (ctx) => {
|
|
39
|
+
const { t } = await resolveTranslations()
|
|
40
|
+
const linkTemplate = t('checkout.search.subtitle.linkTemplate', 'Link Template')
|
|
41
|
+
return {
|
|
42
|
+
text: [`${asSearchText(ctx.record.name)}: ${asSearchText(ctx.record.title)}`],
|
|
43
|
+
presenter: { title: asSearchText(ctx.record.name), subtitle: linkTemplate },
|
|
44
|
+
checksumSource: { record: ctx.record, customFields: ctx.customFields },
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
formatResult: async (ctx) => {
|
|
48
|
+
const { t } = await resolveTranslations()
|
|
49
|
+
return {
|
|
50
|
+
title: asSearchText(ctx.record.name),
|
|
51
|
+
subtitle: t('checkout.search.subtitle.linkTemplate', 'Link Template'),
|
|
52
|
+
icon: 'lucide:file-text',
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
entityId: CHECKOUT_ENTITY_IDS.transaction,
|
|
58
|
+
enabled: true,
|
|
59
|
+
priority: 6,
|
|
60
|
+
fieldPolicy: {
|
|
61
|
+
searchable: ['email', 'firstName', 'lastName'],
|
|
62
|
+
},
|
|
37
63
|
buildSource: async (ctx) => ({
|
|
38
|
-
text: [
|
|
39
|
-
|
|
40
|
-
|
|
64
|
+
text: [
|
|
65
|
+
asSearchText(ctx.record.email),
|
|
66
|
+
asSearchText(ctx.record.firstName),
|
|
67
|
+
asSearchText(ctx.record.lastName),
|
|
68
|
+
],
|
|
69
|
+
fields: {
|
|
70
|
+
email: ctx.record.email,
|
|
71
|
+
first_name: ctx.record.firstName,
|
|
72
|
+
last_name: ctx.record.lastName,
|
|
73
|
+
},
|
|
74
|
+
presenter: {
|
|
75
|
+
title: asSearchText(ctx.record.email),
|
|
76
|
+
subtitle: `${asSearchText(ctx.record.firstName)} ${asSearchText(ctx.record.lastName)}`.trim(),
|
|
77
|
+
},
|
|
78
|
+
checksumSource: { record: ctx.record },
|
|
41
79
|
}),
|
|
42
80
|
formatResult: async (ctx) => ({
|
|
43
|
-
title: asSearchText(ctx.record.
|
|
44
|
-
subtitle:
|
|
45
|
-
icon: 'lucide:
|
|
81
|
+
title: asSearchText(ctx.record.email),
|
|
82
|
+
subtitle: `${asSearchText(ctx.record.firstName)} ${asSearchText(ctx.record.lastName)}`.trim(),
|
|
83
|
+
icon: 'lucide:receipt',
|
|
46
84
|
}),
|
|
47
85
|
},
|
|
48
86
|
],
|
|
@@ -1,14 +1,65 @@
|
|
|
1
1
|
import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
2
3
|
import { ensureCheckoutFieldsetsAndDefinitions } from './seed/customFields'
|
|
3
4
|
import { seedCheckoutExamples } from './seed/examples'
|
|
5
|
+
import { CHECKOUT_EXPIRY_QUEUE } from './workers/transaction-expiry.worker'
|
|
4
6
|
export { DEFAULT_CHECKOUT_CUSTOMER_FIELDS } from './lib/defaults'
|
|
5
7
|
|
|
8
|
+
function stableUuidFromString(input: string): string {
|
|
9
|
+
const bytes = createHash('sha256').update(input).digest().subarray(0, 16)
|
|
10
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x50
|
|
11
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
|
12
|
+
const hex = Buffer.from(bytes).toString('hex')
|
|
13
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type SchedulerServiceLike = {
|
|
17
|
+
register: (registration: {
|
|
18
|
+
id: string
|
|
19
|
+
name: string
|
|
20
|
+
description?: string
|
|
21
|
+
scopeType: 'organization'
|
|
22
|
+
organizationId: string
|
|
23
|
+
tenantId: string
|
|
24
|
+
scheduleType: 'cron' | 'interval'
|
|
25
|
+
scheduleValue: string
|
|
26
|
+
timezone?: string
|
|
27
|
+
targetType: 'queue'
|
|
28
|
+
targetQueue: string
|
|
29
|
+
targetPayload?: Record<string, unknown>
|
|
30
|
+
sourceType: 'module'
|
|
31
|
+
sourceModule: string
|
|
32
|
+
isEnabled?: boolean
|
|
33
|
+
}) => Promise<void>
|
|
34
|
+
}
|
|
35
|
+
|
|
6
36
|
export const setup: ModuleSetupConfig = {
|
|
7
37
|
async seedDefaults(ctx) {
|
|
8
38
|
await ensureCheckoutFieldsetsAndDefinitions(ctx.em, {
|
|
9
39
|
tenantId: ctx.tenantId,
|
|
10
40
|
organizationId: ctx.organizationId,
|
|
11
41
|
})
|
|
42
|
+
|
|
43
|
+
const cradle = ctx.container as { hasRegistration?: (name: string) => boolean }
|
|
44
|
+
if (typeof cradle.hasRegistration !== 'function' || !cradle.hasRegistration('schedulerService')) return
|
|
45
|
+
|
|
46
|
+
const schedulerService = ctx.container.resolve('schedulerService') as SchedulerServiceLike
|
|
47
|
+
await schedulerService.register({
|
|
48
|
+
id: stableUuidFromString(`checkout:transaction-expiry:${ctx.tenantId}:${ctx.organizationId}`),
|
|
49
|
+
name: 'Checkout transaction expiry',
|
|
50
|
+
description: 'Marks stale checkout transactions as expired and runs usage limit updates.',
|
|
51
|
+
scopeType: 'organization',
|
|
52
|
+
organizationId: ctx.organizationId,
|
|
53
|
+
tenantId: ctx.tenantId,
|
|
54
|
+
scheduleType: 'interval',
|
|
55
|
+
scheduleValue: '10m',
|
|
56
|
+
timezone: 'UTC',
|
|
57
|
+
targetType: 'queue',
|
|
58
|
+
targetQueue: CHECKOUT_EXPIRY_QUEUE,
|
|
59
|
+
sourceType: 'module',
|
|
60
|
+
sourceModule: 'checkout',
|
|
61
|
+
isEnabled: true,
|
|
62
|
+
})
|
|
12
63
|
},
|
|
13
64
|
|
|
14
65
|
defaultRoleFeatures: {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import handle from '../search-reindex-transaction-created'
|
|
2
|
+
|
|
3
|
+
const emitEvent = jest.fn(async () => undefined)
|
|
4
|
+
const context = { resolve: jest.fn(() => ({ emitEvent })) }
|
|
5
|
+
|
|
6
|
+
describe('checkout transaction search reindex subscriber', () => {
|
|
7
|
+
beforeEach(() => jest.clearAllMocks())
|
|
8
|
+
|
|
9
|
+
it('bridges transaction creation to the query index', async () => {
|
|
10
|
+
await handle({ transactionId: 'transaction-1', tenantId: 'tenant-1', organizationId: 'org-1' }, context)
|
|
11
|
+
|
|
12
|
+
expect(emitEvent).toHaveBeenCalledWith('query_index.upsert_one', {
|
|
13
|
+
entityType: 'checkout:checkout_transaction',
|
|
14
|
+
recordId: 'transaction-1',
|
|
15
|
+
organizationId: 'org-1',
|
|
16
|
+
tenantId: 'tenant-1',
|
|
17
|
+
crudAction: 'created',
|
|
18
|
+
coverageBaseDelta: 1,
|
|
19
|
+
}, { tenantId: 'tenant-1', organizationId: 'org-1' })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('skips incomplete payloads and unavailable event buses', async () => {
|
|
23
|
+
await handle({ transactionId: 'transaction-1' }, context)
|
|
24
|
+
await handle({ transactionId: 'transaction-1', tenantId: 'tenant-1' }, {
|
|
25
|
+
resolve: () => { throw new Error('unavailable') },
|
|
26
|
+
})
|
|
27
|
+
expect(emitEvent).not.toHaveBeenCalled()
|
|
28
|
+
})
|
|
29
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export const metadata = {
|
|
2
|
+
event: 'checkout.transaction.created',
|
|
3
|
+
persistent: false,
|
|
4
|
+
id: 'checkout:query-index-reindex-transaction-created',
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
type Payload = {
|
|
8
|
+
transactionId?: string
|
|
9
|
+
tenantId?: string | null
|
|
10
|
+
organizationId?: string | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type EventBus = { emitEvent: (name: string, body: unknown, options?: unknown) => Promise<void> }
|
|
14
|
+
type HandlerContext = { resolve: <T = unknown>(name: string) => T }
|
|
15
|
+
|
|
16
|
+
export default async function handle(payload: Payload, ctx: HandlerContext): Promise<void> {
|
|
17
|
+
const recordId = typeof payload.transactionId === 'string' ? payload.transactionId : null
|
|
18
|
+
const tenantId = typeof payload.tenantId === 'string' ? payload.tenantId : null
|
|
19
|
+
if (!recordId || !tenantId) return
|
|
20
|
+
|
|
21
|
+
let eventBus: EventBus | null = null
|
|
22
|
+
try {
|
|
23
|
+
eventBus = ctx.resolve<EventBus>('eventBus')
|
|
24
|
+
} catch {
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
await eventBus.emitEvent('query_index.upsert_one', {
|
|
29
|
+
entityType: 'checkout:checkout_transaction',
|
|
30
|
+
recordId,
|
|
31
|
+
organizationId: payload.organizationId ?? null,
|
|
32
|
+
tenantId,
|
|
33
|
+
crudAction: 'created',
|
|
34
|
+
coverageBaseDelta: 1,
|
|
35
|
+
}, {
|
|
36
|
+
tenantId,
|
|
37
|
+
organizationId: payload.organizationId ?? null,
|
|
38
|
+
}).catch(() => undefined)
|
|
39
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
|
|
2
|
+
import type { JobContext, QueuedJob } from '@open-mercato/queue'
|
|
3
|
+
import { CheckoutTransaction } from '../../data/entities'
|
|
4
|
+
import type { CheckoutExpiryJob } from '../transaction-expiry.worker'
|
|
5
|
+
|
|
6
|
+
const findWithDecryption = jest.fn()
|
|
7
|
+
jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
|
|
8
|
+
findWithDecryption: (...args: unknown[]) => findWithDecryption(...args),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
describe('checkout transaction-expiry worker', () => {
|
|
12
|
+
const mockCommandBus = {
|
|
13
|
+
execute: jest.fn().mockResolvedValue({ ok: true }),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type MockEntityManager = {
|
|
17
|
+
fork: () => MockEntityManager
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const mockEm: MockEntityManager = {
|
|
21
|
+
fork: () => mockEm,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const resolve = <ResolvedValue = unknown>(name: string): ResolvedValue => {
|
|
25
|
+
if (name === 'em') return mockEm as ResolvedValue
|
|
26
|
+
if (name === 'commandBus') return mockCommandBus as ResolvedValue
|
|
27
|
+
throw new Error(`[internal] Missing dependency: ${name}`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const mockContext: JobContext & { resolve: typeof resolve } = {
|
|
31
|
+
resolve,
|
|
32
|
+
jobId: 'job-1',
|
|
33
|
+
attemptNumber: 1,
|
|
34
|
+
queueName: 'checkout-transaction-expiry',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const baseJob = {
|
|
38
|
+
id: 'job-1',
|
|
39
|
+
createdAt: '2026-08-01T00:00:00.000Z',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
jest.clearAllMocks()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('throws an error if tenantId or organizationId is missing', async () => {
|
|
47
|
+
const { default: handle } = await import('../transaction-expiry.worker')
|
|
48
|
+
|
|
49
|
+
await expect(
|
|
50
|
+
handle(
|
|
51
|
+
{
|
|
52
|
+
...baseJob,
|
|
53
|
+
payload: { batchSize: 10 },
|
|
54
|
+
} as unknown as QueuedJob<CheckoutExpiryJob>,
|
|
55
|
+
mockContext,
|
|
56
|
+
),
|
|
57
|
+
).rejects.toThrow('[internal] tenantId and organizationId are required in CheckoutExpiryJob')
|
|
58
|
+
|
|
59
|
+
expect(findWithDecryption).not.toHaveBeenCalled()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('queries only transactions matching the payload tenantId and organizationId and passes decryption scope', async () => {
|
|
63
|
+
const { default: handle } = await import('../transaction-expiry.worker')
|
|
64
|
+
|
|
65
|
+
const mockTransactions = [
|
|
66
|
+
{
|
|
67
|
+
id: 'txn-1',
|
|
68
|
+
organizationId: 'org-123',
|
|
69
|
+
tenantId: 'tenant-456',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'txn-2',
|
|
73
|
+
organizationId: 'org-123',
|
|
74
|
+
tenantId: 'tenant-456',
|
|
75
|
+
},
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
findWithDecryption.mockResolvedValueOnce(mockTransactions)
|
|
79
|
+
|
|
80
|
+
await handle(
|
|
81
|
+
{
|
|
82
|
+
...baseJob,
|
|
83
|
+
payload: {
|
|
84
|
+
batchSize: 50,
|
|
85
|
+
tenantId: 'tenant-456',
|
|
86
|
+
organizationId: 'org-123',
|
|
87
|
+
},
|
|
88
|
+
} satisfies QueuedJob<CheckoutExpiryJob>,
|
|
89
|
+
mockContext,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
expect(findWithDecryption).toHaveBeenCalledWith(
|
|
93
|
+
mockEm,
|
|
94
|
+
CheckoutTransaction,
|
|
95
|
+
expect.objectContaining({
|
|
96
|
+
status: 'processing',
|
|
97
|
+
tenantId: 'tenant-456',
|
|
98
|
+
organizationId: 'org-123',
|
|
99
|
+
}),
|
|
100
|
+
expect.objectContaining({
|
|
101
|
+
limit: 50,
|
|
102
|
+
}),
|
|
103
|
+
{
|
|
104
|
+
tenantId: 'tenant-456',
|
|
105
|
+
organizationId: 'org-123',
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
expect(mockCommandBus.execute).toHaveBeenCalledTimes(2)
|
|
110
|
+
expect(mockCommandBus.execute).toHaveBeenNthCalledWith(
|
|
111
|
+
1,
|
|
112
|
+
'checkout.transaction.updateStatus',
|
|
113
|
+
expect.objectContaining({
|
|
114
|
+
input: {
|
|
115
|
+
id: 'txn-1',
|
|
116
|
+
status: 'expired',
|
|
117
|
+
paymentStatus: 'expired',
|
|
118
|
+
organizationId: 'org-123',
|
|
119
|
+
tenantId: 'tenant-456',
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -14,6 +14,8 @@ const EXPIRY_TIMEOUT_MS = 2 * 60 * 60 * 1000 // 2 hours
|
|
|
14
14
|
|
|
15
15
|
export type CheckoutExpiryJob = {
|
|
16
16
|
batchSize?: number
|
|
17
|
+
tenantId: string
|
|
18
|
+
organizationId: string
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export const metadata: WorkerMeta = {
|
|
@@ -32,14 +34,23 @@ export default async function handle(job: QueuedJob<CheckoutExpiryJob>, ctx: Han
|
|
|
32
34
|
const batchSize = job.payload?.batchSize ?? 100
|
|
33
35
|
const cutoff = new Date(Date.now() - EXPIRY_TIMEOUT_MS)
|
|
34
36
|
|
|
37
|
+
if (!job.payload?.tenantId || !job.payload?.organizationId) {
|
|
38
|
+
throw new Error('[internal] tenantId and organizationId are required in CheckoutExpiryJob')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { tenantId, organizationId } = job.payload
|
|
42
|
+
|
|
35
43
|
const staleTransactions = await findWithDecryption(
|
|
36
44
|
em,
|
|
37
45
|
CheckoutTransaction,
|
|
38
46
|
{
|
|
39
47
|
status: 'processing',
|
|
40
48
|
createdAt: { $lt: cutoff },
|
|
49
|
+
tenantId,
|
|
50
|
+
organizationId,
|
|
41
51
|
},
|
|
42
52
|
{ limit: batchSize, orderBy: { createdAt: 'ASC' } },
|
|
53
|
+
{ tenantId, organizationId },
|
|
43
54
|
)
|
|
44
55
|
|
|
45
56
|
for (const transaction of staleTransactions) {
|