@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,363 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tests for the state-machine guard added to updateTransactionStatusCommand.
|
|
5
|
+
*
|
|
6
|
+
* Exercises:
|
|
7
|
+
* 1. A terminal → non-terminal transition is rejected with a 409 before the
|
|
8
|
+
* nativeUpdate is even called.
|
|
9
|
+
* 2. A non-terminal → terminal transition succeeds (nativeUpdate called with
|
|
10
|
+
* the correct WHERE clause locking the current status).
|
|
11
|
+
* 3. When nativeUpdate returns 0 (concurrent write won the race), the command
|
|
12
|
+
* throws a 409 (concurrent_status_update) and does NOT proceed to emit
|
|
13
|
+
* terminal events.
|
|
14
|
+
* 4. When nativeUpdate returns 1, the command succeeds and emits the correct
|
|
15
|
+
* terminal event.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { commandRegistry } from '@open-mercato/shared/lib/commands/registry'
|
|
19
|
+
import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'
|
|
20
|
+
import { CheckoutTransaction } from '../../data/entities'
|
|
21
|
+
|
|
22
|
+
// Register the transaction commands
|
|
23
|
+
import '../transactions'
|
|
24
|
+
|
|
25
|
+
jest.mock('../../events', () => ({
|
|
26
|
+
emitCheckoutEvent: jest.fn(async () => undefined),
|
|
27
|
+
}))
|
|
28
|
+
|
|
29
|
+
const { emitCheckoutEvent } = jest.requireMock('../../events') as {
|
|
30
|
+
emitCheckoutEvent: jest.Mock
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ORG_ID = '123e4567-e89b-12d3-a456-426614174000'
|
|
34
|
+
const TENANT_ID = '123e4567-e89b-12d3-a456-426614174001'
|
|
35
|
+
const LINK_ID = '123e4567-e89b-12d3-a456-426614174010'
|
|
36
|
+
const TX_ID = '123e4567-e89b-12d3-a456-426614174020'
|
|
37
|
+
|
|
38
|
+
function makeTransaction(status: CheckoutTransaction['status']) {
|
|
39
|
+
return {
|
|
40
|
+
id: TX_ID,
|
|
41
|
+
linkId: LINK_ID,
|
|
42
|
+
organizationId: ORG_ID,
|
|
43
|
+
tenantId: TENANT_ID,
|
|
44
|
+
status,
|
|
45
|
+
paymentStatus: null,
|
|
46
|
+
gatewayTransactionId: null,
|
|
47
|
+
amount: '100.00',
|
|
48
|
+
currencyCode: 'USD',
|
|
49
|
+
createdAt: new Date(),
|
|
50
|
+
updatedAt: new Date(),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function makeLink() {
|
|
55
|
+
return {
|
|
56
|
+
id: LINK_ID,
|
|
57
|
+
organizationId: ORG_ID,
|
|
58
|
+
tenantId: TENANT_ID,
|
|
59
|
+
slug: 'test-link',
|
|
60
|
+
templateId: null,
|
|
61
|
+
gatewayProviderKey: 'stripe',
|
|
62
|
+
activeReservationCount: 1,
|
|
63
|
+
completionCount: 0,
|
|
64
|
+
maxCompletions: null,
|
|
65
|
+
isLocked: false,
|
|
66
|
+
deletedAt: null,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type MockEm = {
|
|
71
|
+
findOne: jest.Mock
|
|
72
|
+
nativeUpdate: jest.Mock
|
|
73
|
+
flush: jest.Mock
|
|
74
|
+
refresh: jest.Mock
|
|
75
|
+
transactional: (fn: (tx: MockEm) => Promise<unknown>) => Promise<unknown>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function makeMockEm(
|
|
79
|
+
transaction: ReturnType<typeof makeTransaction>,
|
|
80
|
+
nativeUpdateResult = 1,
|
|
81
|
+
// When nativeUpdate returns 0, the post-CAS findOne should return a
|
|
82
|
+
// different status to simulate the winning writer's value.
|
|
83
|
+
postCasStatus?: CheckoutTransaction['status'],
|
|
84
|
+
): MockEm {
|
|
85
|
+
let findOneCallCount = 0
|
|
86
|
+
const mockTx: MockEm = {
|
|
87
|
+
findOne: jest.fn(async (_entity: unknown, filter: Record<string, unknown>) => {
|
|
88
|
+
findOneCallCount++
|
|
89
|
+
if (filter.id === TX_ID) {
|
|
90
|
+
// After a CAS miss (nativeUpdate=0) the command re-reads the row with
|
|
91
|
+
// refresh:true to fetch the winning writer's status. Return the
|
|
92
|
+
// post-CAS status on subsequent calls when one is provided.
|
|
93
|
+
if (findOneCallCount > 1 && postCasStatus !== undefined) {
|
|
94
|
+
return { ...transaction, status: postCasStatus }
|
|
95
|
+
}
|
|
96
|
+
return transaction
|
|
97
|
+
}
|
|
98
|
+
if (filter.id === LINK_ID) return makeLink()
|
|
99
|
+
return null
|
|
100
|
+
}),
|
|
101
|
+
nativeUpdate: jest.fn(async () => nativeUpdateResult),
|
|
102
|
+
flush: jest.fn(async () => undefined),
|
|
103
|
+
refresh: jest.fn(async () => undefined),
|
|
104
|
+
transactional: (fn) => fn(mockTx),
|
|
105
|
+
}
|
|
106
|
+
return mockTx
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function makeContext(em: MockEm): CommandRuntimeContext {
|
|
110
|
+
return {
|
|
111
|
+
container: {
|
|
112
|
+
resolve: (token: string) => {
|
|
113
|
+
if (token === 'em') return em
|
|
114
|
+
return null
|
|
115
|
+
},
|
|
116
|
+
} as unknown as CommandRuntimeContext['container'],
|
|
117
|
+
auth: null,
|
|
118
|
+
organizationScope: null,
|
|
119
|
+
selectedOrganizationId: ORG_ID,
|
|
120
|
+
organizationIds: [ORG_ID],
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function baseInput(status: string) {
|
|
125
|
+
return {
|
|
126
|
+
id: TX_ID,
|
|
127
|
+
status,
|
|
128
|
+
paymentStatus: null,
|
|
129
|
+
gatewayTransactionId: null,
|
|
130
|
+
organizationId: ORG_ID,
|
|
131
|
+
tenantId: TENANT_ID,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function runUpdateStatus(em: MockEm, status: string) {
|
|
136
|
+
const handler = commandRegistry.get('checkout.transaction.updateStatus')
|
|
137
|
+
if (!handler) throw new Error('checkout.transaction.updateStatus not registered')
|
|
138
|
+
return handler.execute(baseInput(status), makeContext(em))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
describe('updateTransactionStatusCommand — state-machine guard', () => {
|
|
142
|
+
beforeEach(() => {
|
|
143
|
+
jest.clearAllMocks()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('valid transitions', () => {
|
|
147
|
+
it('processing → completed: calls nativeUpdate with status=processing in the WHERE clause', async () => {
|
|
148
|
+
const transaction = makeTransaction('processing')
|
|
149
|
+
const em = makeMockEm(transaction, 1)
|
|
150
|
+
|
|
151
|
+
await runUpdateStatus(em, 'completed')
|
|
152
|
+
|
|
153
|
+
expect(em.nativeUpdate).toHaveBeenCalledWith(
|
|
154
|
+
expect.anything(),
|
|
155
|
+
expect.objectContaining({
|
|
156
|
+
id: TX_ID,
|
|
157
|
+
organizationId: ORG_ID,
|
|
158
|
+
tenantId: TENANT_ID,
|
|
159
|
+
status: 'processing', // pins the current status for atomicity
|
|
160
|
+
}),
|
|
161
|
+
expect.objectContaining({ status: 'completed' }),
|
|
162
|
+
)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('processing → completed: emits checkout.transaction.completed event', async () => {
|
|
166
|
+
const transaction = makeTransaction('processing')
|
|
167
|
+
const em = makeMockEm(transaction, 1)
|
|
168
|
+
|
|
169
|
+
await runUpdateStatus(em, 'completed')
|
|
170
|
+
|
|
171
|
+
expect(emitCheckoutEvent).toHaveBeenCalledWith(
|
|
172
|
+
'checkout.transaction.completed',
|
|
173
|
+
expect.any(Object),
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('pending → processing: nativeUpdate is called with correct WHERE', async () => {
|
|
178
|
+
const transaction = makeTransaction('pending')
|
|
179
|
+
const em = makeMockEm(transaction, 1)
|
|
180
|
+
|
|
181
|
+
await runUpdateStatus(em, 'processing')
|
|
182
|
+
|
|
183
|
+
expect(em.nativeUpdate).toHaveBeenCalledWith(
|
|
184
|
+
expect.anything(),
|
|
185
|
+
expect.objectContaining({ status: 'pending' }),
|
|
186
|
+
expect.objectContaining({ status: 'processing' }),
|
|
187
|
+
)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('pending → processing: does NOT emit a terminal event', async () => {
|
|
191
|
+
const transaction = makeTransaction('pending')
|
|
192
|
+
const em = makeMockEm(transaction, 1)
|
|
193
|
+
|
|
194
|
+
await runUpdateStatus(em, 'processing')
|
|
195
|
+
|
|
196
|
+
expect(emitCheckoutEvent).not.toHaveBeenCalledWith(
|
|
197
|
+
'checkout.transaction.completed',
|
|
198
|
+
expect.any(Object),
|
|
199
|
+
)
|
|
200
|
+
expect(emitCheckoutEvent).not.toHaveBeenCalledWith(
|
|
201
|
+
'checkout.transaction.failed',
|
|
202
|
+
expect.any(Object),
|
|
203
|
+
)
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('regression guard — terminal → non-terminal (the race condition)', () => {
|
|
208
|
+
it('completed → processing: throws 409 with code invalid_status_transition', async () => {
|
|
209
|
+
expect.assertions(3)
|
|
210
|
+
const transaction = makeTransaction('completed')
|
|
211
|
+
const em = makeMockEm(transaction, 1)
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
await runUpdateStatus(em, 'processing')
|
|
215
|
+
} catch (err) {
|
|
216
|
+
const error = err as { status?: number; body?: Record<string, unknown> }
|
|
217
|
+
expect(error.status).toBe(409)
|
|
218
|
+
expect(error.body?.code).toBe('invalid_status_transition')
|
|
219
|
+
expect(em.nativeUpdate).not.toHaveBeenCalled()
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('completed → failed: throws 409', async () => {
|
|
224
|
+
expect.assertions(2)
|
|
225
|
+
const transaction = makeTransaction('completed')
|
|
226
|
+
const em = makeMockEm(transaction, 1)
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
await runUpdateStatus(em, 'failed')
|
|
230
|
+
} catch (err) {
|
|
231
|
+
const error = err as { status?: number; body?: Record<string, unknown> }
|
|
232
|
+
expect(error.status).toBe(409)
|
|
233
|
+
expect(em.nativeUpdate).not.toHaveBeenCalled()
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('expired → processing: throws 409', async () => {
|
|
238
|
+
expect.assertions(2)
|
|
239
|
+
const transaction = makeTransaction('expired')
|
|
240
|
+
const em = makeMockEm(transaction, 1)
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
await runUpdateStatus(em, 'processing')
|
|
244
|
+
} catch (err) {
|
|
245
|
+
const error = err as { status?: number; body?: Record<string, unknown> }
|
|
246
|
+
expect(error.status).toBe(409)
|
|
247
|
+
expect(em.nativeUpdate).not.toHaveBeenCalled()
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('failed → completed: throws 409', async () => {
|
|
252
|
+
expect.assertions(2)
|
|
253
|
+
const transaction = makeTransaction('failed')
|
|
254
|
+
const em = makeMockEm(transaction, 1)
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
await runUpdateStatus(em, 'completed')
|
|
258
|
+
} catch (err) {
|
|
259
|
+
const error = err as { status?: number; body?: Record<string, unknown> }
|
|
260
|
+
expect(error.status).toBe(409)
|
|
261
|
+
expect(em.nativeUpdate).not.toHaveBeenCalled()
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
describe('TOCTOU guard — nativeUpdate returns 0 (concurrent write wins)', () => {
|
|
267
|
+
it('nativeUpdate=0 throws 409 with code concurrent_status_update', async () => {
|
|
268
|
+
expect.assertions(5)
|
|
269
|
+
// Transaction is still processing from our read, but another writer won
|
|
270
|
+
// the race and set status to completed. The post-CAS re-read (refresh:true)
|
|
271
|
+
// should return the winner's status, not the stale identity-mapped value.
|
|
272
|
+
const transaction = makeTransaction('processing')
|
|
273
|
+
const em = makeMockEm(transaction, 0, 'completed')
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
await runUpdateStatus(em, 'completed')
|
|
277
|
+
} catch (err) {
|
|
278
|
+
const error = err as { status?: number; body?: Record<string, unknown> }
|
|
279
|
+
expect(error.status).toBe(409)
|
|
280
|
+
expect(error.body?.code).toBe('concurrent_status_update')
|
|
281
|
+
expect(error.body?.expectedStatus).toBe('processing')
|
|
282
|
+
// currentStatus must reflect the winning writer's value, not the stale snapshot
|
|
283
|
+
expect(error.body?.currentStatus).toBe('completed')
|
|
284
|
+
// The terminal event must NOT be emitted when the update was a no-op
|
|
285
|
+
expect(emitCheckoutEvent).not.toHaveBeenCalledWith(
|
|
286
|
+
'checkout.transaction.completed',
|
|
287
|
+
expect.any(Object),
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('nativeUpdate=0 does NOT emit any event', async () => {
|
|
293
|
+
expect.assertions(1)
|
|
294
|
+
const transaction = makeTransaction('processing')
|
|
295
|
+
const em = makeMockEm(transaction, 0)
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
await runUpdateStatus(em, 'completed')
|
|
299
|
+
} catch {
|
|
300
|
+
// Expected 409 — assert no events were emitted
|
|
301
|
+
expect(emitCheckoutEvent).not.toHaveBeenCalled()
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
describe('same-state transitions (idempotency support)', () => {
|
|
307
|
+
it('processing → processing: succeeds and calls nativeUpdate', async () => {
|
|
308
|
+
const transaction = makeTransaction('processing')
|
|
309
|
+
const em = makeMockEm(transaction, 1)
|
|
310
|
+
|
|
311
|
+
await runUpdateStatus(em, 'processing')
|
|
312
|
+
|
|
313
|
+
expect(em.nativeUpdate).toHaveBeenCalledWith(
|
|
314
|
+
expect.anything(),
|
|
315
|
+
expect.objectContaining({ status: 'processing' }),
|
|
316
|
+
expect.objectContaining({ status: 'processing' }),
|
|
317
|
+
)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('completed → completed: succeeds and calls nativeUpdate', async () => {
|
|
321
|
+
const transaction = makeTransaction('completed')
|
|
322
|
+
const em = makeMockEm(transaction, 1)
|
|
323
|
+
|
|
324
|
+
await runUpdateStatus(em, 'completed')
|
|
325
|
+
|
|
326
|
+
expect(em.nativeUpdate).toHaveBeenCalledWith(
|
|
327
|
+
expect.anything(),
|
|
328
|
+
expect.objectContaining({ status: 'completed' }),
|
|
329
|
+
expect.objectContaining({ status: 'completed' }),
|
|
330
|
+
)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('completed → completed: does NOT re-emit checkout.transaction.completed (idempotent redelivery guard)', async () => {
|
|
334
|
+
const transaction = makeTransaction('completed')
|
|
335
|
+
const em = makeMockEm(transaction, 1)
|
|
336
|
+
|
|
337
|
+
await runUpdateStatus(em, 'completed')
|
|
338
|
+
|
|
339
|
+
expect(emitCheckoutEvent).not.toHaveBeenCalledWith(
|
|
340
|
+
'checkout.transaction.completed',
|
|
341
|
+
expect.any(Object),
|
|
342
|
+
)
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('authorized → captured (both → completed): CAS succeeds but terminal event is NOT re-emitted', async () => {
|
|
346
|
+
// Both payment_gateways.payment.authorized and .captured map to 'completed'.
|
|
347
|
+
// The second delivery (captured) finds the transaction already completed;
|
|
348
|
+
// the CAS still succeeds (same-state), but checkout.transaction.completed
|
|
349
|
+
// must NOT fire a second time so the customer does not receive a duplicate notification.
|
|
350
|
+
const transaction = makeTransaction('completed')
|
|
351
|
+
const em = makeMockEm(transaction, 1)
|
|
352
|
+
|
|
353
|
+
await runUpdateStatus(em, 'completed')
|
|
354
|
+
|
|
355
|
+
expect(em.nativeUpdate).toHaveBeenCalledTimes(1)
|
|
356
|
+
expect(emitCheckoutEvent).not.toHaveBeenCalledWith(
|
|
357
|
+
'checkout.transaction.completed',
|
|
358
|
+
expect.any(Object),
|
|
359
|
+
)
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
})
|
|
363
|
+
|
|
@@ -520,5 +520,9 @@ registerCommand(updateLinkCommand)
|
|
|
520
520
|
registerCommand(deleteLinkCommand)
|
|
521
521
|
|
|
522
522
|
export function serializeLinkRecord(record: CheckoutLink) {
|
|
523
|
-
|
|
523
|
+
// completionCount is link-only state (templates have no completions), so it is
|
|
524
|
+
// added here rather than in the shared template/link serializer. It is the
|
|
525
|
+
// observable proof that a terminal transaction was applied exactly once, which
|
|
526
|
+
// TC-CHKT-043 asserts and could not previously read from any API.
|
|
527
|
+
return { ...serializeTemplateOrLink(record), completionCount: record.completionCount ?? 0 }
|
|
524
528
|
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
parseCheckoutInput,
|
|
13
13
|
toMoneyString,
|
|
14
14
|
} from '../lib/utils'
|
|
15
|
+
import { assertValidCheckoutStatusTransition } from '../lib/transaction-status-machine'
|
|
15
16
|
|
|
16
17
|
function resolveTransactionScope(input: { tenantId?: string | null; organizationId?: string | null }) {
|
|
17
18
|
if (!input.organizationId || !input.tenantId) {
|
|
@@ -168,11 +169,63 @@ const updateTransactionStatusCommand: CommandHandler<Record<string, unknown>, {
|
|
|
168
169
|
const previousTerminal = isTerminalCheckoutStatus(previousStatus)
|
|
169
170
|
const nextTerminal = isTerminalCheckoutStatus(nextStatus)
|
|
170
171
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
172
|
+
// State-machine guard: reject any transition not permitted by the
|
|
173
|
+
// VALID_CHECKOUT_TRANSITIONS map (e.g. completed → processing).
|
|
174
|
+
// This runs inside the DB transaction so the check and the write share
|
|
175
|
+
// the same serialisable snapshot.
|
|
176
|
+
assertValidCheckoutStatusTransition(previousStatus, nextStatus)
|
|
177
|
+
|
|
178
|
+
// Resolve the new field values before the write.
|
|
179
|
+
const newPaymentStatus = parsed.paymentStatus ?? transaction.paymentStatus ?? null
|
|
180
|
+
const newGatewayTransactionId = parsed.gatewayTransactionId ?? transaction.gatewayTransactionId ?? null
|
|
181
|
+
|
|
182
|
+
// Atomic compare-and-swap: the WHERE clause pins the current status so
|
|
183
|
+
// a concurrent writer that already advanced the status to a terminal
|
|
184
|
+
// state will cause this update to match 0 rows — eliminating the TOCTOU
|
|
185
|
+
// window between the findOne above and this write.
|
|
186
|
+
const affected = await tx.nativeUpdate(
|
|
187
|
+
CheckoutTransaction,
|
|
188
|
+
{
|
|
189
|
+
id: parsed.id,
|
|
190
|
+
organizationId: scope.organizationId,
|
|
191
|
+
tenantId: scope.tenantId,
|
|
192
|
+
status: previousStatus,
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
status: nextStatus,
|
|
196
|
+
paymentStatus: newPaymentStatus,
|
|
197
|
+
gatewayTransactionId: newGatewayTransactionId,
|
|
198
|
+
updatedAt: new Date(),
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if (affected === 0) {
|
|
203
|
+
// A concurrent writer already advanced the status. Force a DB round-trip
|
|
204
|
+
// (refresh: true bypasses the MikroORM identity map) so the reported
|
|
205
|
+
// currentStatus reflects the winning writer's value, not the stale snapshot.
|
|
206
|
+
const actualTx = await tx.findOne(
|
|
207
|
+
CheckoutTransaction,
|
|
208
|
+
{ id: parsed.id, organizationId: scope.organizationId, tenantId: scope.tenantId },
|
|
209
|
+
{ fields: ['status'], refresh: true },
|
|
210
|
+
)
|
|
211
|
+
throw new CrudHttpError(409, {
|
|
212
|
+
error: `[internal] Transaction status was already updated by a concurrent process (expected "${previousStatus}", actual "${actualTx?.status ?? 'unknown'}")`,
|
|
213
|
+
code: 'concurrent_status_update',
|
|
214
|
+
expectedStatus: previousStatus,
|
|
215
|
+
currentStatus: actualTx?.status ?? 'unknown',
|
|
216
|
+
requestedStatus: nextStatus,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
175
219
|
|
|
220
|
+
// Sync the entity state in the unit of work by refreshing it.
|
|
221
|
+
// This loads the updated values (status, paymentStatus, gatewayTransactionId)
|
|
222
|
+
// from the DB and marks the entity as clean, avoiding a redundant second write
|
|
223
|
+
// during the subsequent tx.flush().
|
|
224
|
+
await tx.refresh(transaction)
|
|
225
|
+
|
|
226
|
+
// Only apply terminal link state and emit the terminal event when the
|
|
227
|
+
// status actually changes — prevents double-notification on idempotent
|
|
228
|
+
// redeliveries (e.g. authorized → captured, both mapping to 'completed').
|
|
176
229
|
if (!previousTerminal && nextTerminal) {
|
|
177
230
|
const { usageLimitReached } = applyTerminalTransactionState(link, nextStatus)
|
|
178
231
|
await tx.flush()
|
|
@@ -182,7 +235,7 @@ const updateTransactionStatusCommand: CommandHandler<Record<string, unknown>, {
|
|
|
182
235
|
usageLimitReachedLinkSlug = link.slug
|
|
183
236
|
}
|
|
184
237
|
}
|
|
185
|
-
if (nextTerminal) {
|
|
238
|
+
if (nextTerminal && previousStatus !== nextStatus) {
|
|
186
239
|
terminalEventPayload = {
|
|
187
240
|
transactionId: transaction.id,
|
|
188
241
|
linkId: transaction.linkId,
|
|
@@ -200,14 +253,16 @@ const updateTransactionStatusCommand: CommandHandler<Record<string, unknown>, {
|
|
|
200
253
|
}
|
|
201
254
|
}
|
|
202
255
|
})
|
|
203
|
-
if (
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
256
|
+
if (terminalEventPayload !== null) {
|
|
257
|
+
if (parsed.status === 'completed') {
|
|
258
|
+
await emitCheckoutEvent('checkout.transaction.completed', terminalEventPayload).catch(() => undefined)
|
|
259
|
+
} else if (parsed.status === 'failed') {
|
|
260
|
+
await emitCheckoutEvent('checkout.transaction.failed', terminalEventPayload).catch(() => undefined)
|
|
261
|
+
} else if (parsed.status === 'cancelled') {
|
|
262
|
+
await emitCheckoutEvent('checkout.transaction.cancelled', terminalEventPayload).catch(() => undefined)
|
|
263
|
+
} else if (parsed.status === 'expired') {
|
|
264
|
+
await emitCheckoutEvent('checkout.transaction.expired', terminalEventPayload).catch(() => undefined)
|
|
265
|
+
}
|
|
211
266
|
}
|
|
212
267
|
if (emitUsageLimitReached && usageLimitReachedLinkId && usageLimitReachedLinkSlug) {
|
|
213
268
|
await emitCheckoutEvent('checkout.link.usageLimitReached', {
|
|
@@ -139,7 +139,7 @@ export function CustomerFieldsEditor({ value, onChange, errors }: Props) {
|
|
|
139
139
|
|
|
140
140
|
return (
|
|
141
141
|
<div className="space-y-4">
|
|
142
|
-
<Alert
|
|
142
|
+
<Alert status="information">
|
|
143
143
|
<AlertDescription>
|
|
144
144
|
{t('checkout.customerFieldsEditor.notices.defaultFields')}
|
|
145
145
|
</AlertDescription>
|
|
@@ -328,7 +328,7 @@ export function CustomerFieldsEditor({ value, onChange, errors }: Props) {
|
|
|
328
328
|
</div>
|
|
329
329
|
) : (
|
|
330
330
|
<div className="px-4 py-8">
|
|
331
|
-
<Alert
|
|
331
|
+
<Alert status="information">
|
|
332
332
|
<AlertDescription>
|
|
333
333
|
{t('checkout.customerFieldsEditor.notices.empty')}
|
|
334
334
|
</AlertDescription>
|
|
@@ -84,7 +84,7 @@ export function GatewaySettingsFields({ providerKey, value, onChange }: Props) {
|
|
|
84
84
|
const fields = descriptor?.sessionConfig?.fields ?? []
|
|
85
85
|
if (!providerKey) {
|
|
86
86
|
return (
|
|
87
|
-
<Alert
|
|
87
|
+
<Alert status="information">
|
|
88
88
|
<AlertDescription>
|
|
89
89
|
{t('checkout.gatewaySettings.notices.chooseProvider')}
|
|
90
90
|
</AlertDescription>
|
|
@@ -93,7 +93,7 @@ export function GatewaySettingsFields({ providerKey, value, onChange }: Props) {
|
|
|
93
93
|
}
|
|
94
94
|
if (!fields.length) {
|
|
95
95
|
return (
|
|
96
|
-
<Alert
|
|
96
|
+
<Alert status="information">
|
|
97
97
|
<AlertDescription>
|
|
98
98
|
{t('checkout.gatewaySettings.notices.noSettings')}
|
|
99
99
|
</AlertDescription>
|
|
@@ -408,7 +408,7 @@ function PriceListEditor({
|
|
|
408
408
|
|
|
409
409
|
return (
|
|
410
410
|
<div className="space-y-4">
|
|
411
|
-
<Alert
|
|
411
|
+
<Alert status="information">
|
|
412
412
|
<AlertDescription>
|
|
413
413
|
{t('checkout.linkTemplateForm.priceList.notices.singleCurrency')}
|
|
414
414
|
</AlertDescription>
|
|
@@ -486,7 +486,7 @@ function PriceListEditor({
|
|
|
486
486
|
</div>
|
|
487
487
|
) : (
|
|
488
488
|
<div className="px-4 py-8">
|
|
489
|
-
<Alert
|
|
489
|
+
<Alert status="information">
|
|
490
490
|
<AlertDescription>
|
|
491
491
|
{t('checkout.linkTemplateForm.priceList.notices.empty')}
|
|
492
492
|
</AlertDescription>
|
|
@@ -946,7 +946,7 @@ function CustomerDetailsSection({ values, setValue, errors }: CrudFormGroupCompo
|
|
|
946
946
|
|
|
947
947
|
{collectCustomerDetails ? (
|
|
948
948
|
<>
|
|
949
|
-
<Alert
|
|
949
|
+
<Alert status="information">
|
|
950
950
|
<AlertDescription>
|
|
951
951
|
{t('checkout.linkTemplateForm.customerDetails.notices.simpleLink')}
|
|
952
952
|
</AlertDescription>
|
|
@@ -959,7 +959,7 @@ function CustomerDetailsSection({ values, setValue, errors }: CrudFormGroupCompo
|
|
|
959
959
|
/>
|
|
960
960
|
</>
|
|
961
961
|
) : (
|
|
962
|
-
<Alert
|
|
962
|
+
<Alert status="information">
|
|
963
963
|
<AlertDescription>
|
|
964
964
|
{t('checkout.linkTemplateForm.customerDetails.notices.disabled')}
|
|
965
965
|
</AlertDescription>
|
|
@@ -993,7 +993,7 @@ function LegalSection({ values, setValue, errors }: CrudFormGroupComponentProps)
|
|
|
993
993
|
|
|
994
994
|
return (
|
|
995
995
|
<div className="space-y-4">
|
|
996
|
-
<Alert
|
|
996
|
+
<Alert status="information">
|
|
997
997
|
<AlertDescription>
|
|
998
998
|
{t('checkout.linkTemplateForm.legal.notice')}
|
|
999
999
|
</AlertDescription>
|
|
@@ -1103,7 +1103,7 @@ function MessagesSection({ values, setValue, errors }: CrudFormGroupComponentPro
|
|
|
1103
1103
|
|
|
1104
1104
|
return (
|
|
1105
1105
|
<div className="space-y-4">
|
|
1106
|
-
<Alert
|
|
1106
|
+
<Alert status="information">
|
|
1107
1107
|
<AlertDescription>
|
|
1108
1108
|
{t('checkout.linkTemplateForm.messages.notice')}
|
|
1109
1109
|
</AlertDescription>
|
|
@@ -1202,7 +1202,7 @@ function EmailsSection({ values, setValue, errors }: CrudFormGroupComponentProps
|
|
|
1202
1202
|
|
|
1203
1203
|
return (
|
|
1204
1204
|
<div className="space-y-4">
|
|
1205
|
-
<Alert
|
|
1205
|
+
<Alert status="information">
|
|
1206
1206
|
<AlertDescription>
|
|
1207
1207
|
{t('checkout.linkTemplateForm.emails.notice')}
|
|
1208
1208
|
</AlertDescription>
|
|
@@ -1557,7 +1557,7 @@ export function LinkTemplateForm({ mode, recordId }: Props) {
|
|
|
1557
1557
|
[mode, recordId],
|
|
1558
1558
|
)
|
|
1559
1559
|
const lockedNotice = isLocked ? (
|
|
1560
|
-
<Alert
|
|
1560
|
+
<Alert status="warning">
|
|
1561
1561
|
<AlertTitle>{t('checkout.linkTemplateForm.locked.title')}</AlertTitle>
|
|
1562
1562
|
<AlertDescription>{t('checkout.linkTemplateForm.locked.description')}</AlertDescription>
|
|
1563
1563
|
</Alert>
|
|
@@ -175,7 +175,7 @@ export function LogoUploadField({ entityId, recordId, attachmentId, logoUrl, err
|
|
|
175
175
|
</div>
|
|
176
176
|
|
|
177
177
|
{attachmentId ? (
|
|
178
|
-
<Alert
|
|
178
|
+
<Alert status="information">
|
|
179
179
|
<AlertDescription>
|
|
180
180
|
{t('checkout.logoUpload.notices.attachmentWins')}
|
|
181
181
|
</AlertDescription>
|