@open-mercato/checkout 0.6.7-develop.6842.1.8cd8a8883c → 0.6.7-develop.6859.1.e4727b568a
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-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/commands/transactions.js +45 -13
- package/dist/modules/checkout/commands/transactions.js.map +2 -2
- package/dist/modules/checkout/lib/transaction-status-machine.js +31 -0
- package/dist/modules/checkout/lib/transaction-status-machine.js.map +7 -0
- package/package.json +5 -5
- package/src/modules/checkout/__integration__/TC-CHKT-043-concurrent-status-update.spec.ts +110 -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/commands/__tests__/update-transaction-status.test.ts +363 -0
- package/src/modules/checkout/commands/transactions.ts +68 -13
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/checkout",
|
|
3
|
-
"version": "0.6.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6859.1.e4727b568a",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -62,18 +62,18 @@
|
|
|
62
62
|
}
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@open-mercato/core": "0.6.7-develop.
|
|
66
|
-
"@open-mercato/ui": "0.6.7-develop.
|
|
65
|
+
"@open-mercato/core": "0.6.7-develop.6859.1.e4727b568a",
|
|
66
|
+
"@open-mercato/ui": "0.6.7-develop.6859.1.e4727b568a",
|
|
67
67
|
"bcryptjs": "^3.0.3"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
70
|
"@mikro-orm/postgresql": "^7.0.14",
|
|
71
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
71
|
+
"@open-mercato/shared": "0.6.7-develop.6859.1.e4727b568a",
|
|
72
72
|
"react": "^19.0.0",
|
|
73
73
|
"react-dom": "^19.0.0"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
76
|
+
"@open-mercato/shared": "0.6.7-develop.6859.1.e4727b568a",
|
|
77
77
|
"@types/jest": "^30.0.0",
|
|
78
78
|
"@types/react": "^19.2.17",
|
|
79
79
|
"@types/react-dom": "^19.2.3",
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
const updatedLinkBody = await readJsonSafe<{ link?: { completionCount?: number } }>(updatedLinkResponse)
|
|
100
|
+
expect(updatedLinkBody?.link?.completionCount).toBe(1)
|
|
101
|
+
|
|
102
|
+
// 3. The stored transaction must have a gatewayTransactionId from the webhook.
|
|
103
|
+
const storedTransaction = await readCheckoutTransaction(request, token!, transactionId)
|
|
104
|
+
expect(storedTransaction.status).toBe('completed')
|
|
105
|
+
expect(storedTransaction.gatewayTransactionId).toBeTruthy()
|
|
106
|
+
} finally {
|
|
107
|
+
await deleteCheckoutEntityIfExists(request, token, 'links', linkId)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -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)
|