@open-mercato/checkout 0.10.1-develop.7716.1.cefe7975df → 0.10.1-develop.7721.1.b5c4a00a68
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-046-pay-link-usage-counters.spec.js +107 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-046-pay-link-usage-counters.spec.js.map +7 -0
- package/dist/modules/checkout/__integration__/helpers/fixtures.js.map +2 -2
- package/dist/modules/checkout/commands/links.js +6 -0
- package/dist/modules/checkout/commands/links.js.map +2 -2
- package/dist/modules/checkout/commands/shared.js +1 -4
- package/dist/modules/checkout/commands/shared.js.map +2 -2
- package/dist/modules/checkout/commands/transactions.js +38 -18
- package/dist/modules/checkout/commands/transactions.js.map +2 -2
- package/package.json +5 -5
- package/src/modules/checkout/__integration__/TC-CHKT-046-pay-link-usage-counters.spec.ts +136 -0
- package/src/modules/checkout/__integration__/helpers/fixtures.ts +1 -0
- package/src/modules/checkout/commands/__tests__/create-transaction-reservation.test.ts +124 -0
- package/src/modules/checkout/commands/__tests__/link-undo-runtime-counters.test.ts +222 -0
- package/src/modules/checkout/commands/__tests__/update-transaction-status.test.ts +124 -2
- package/src/modules/checkout/commands/links.ts +6 -0
- package/src/modules/checkout/commands/shared.ts +5 -3
- package/src/modules/checkout/commands/transactions.ts +42 -18
- package/src/modules/checkout/i18n/de.json +2 -0
- package/src/modules/checkout/i18n/en.json +2 -0
- package/src/modules/checkout/i18n/es.json +2 -0
- package/src/modules/checkout/i18n/ko.json +2 -0
- package/src/modules/checkout/i18n/pl.json +2 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
import { commandRegistry } from '@open-mercato/shared/lib/commands/registry'
|
|
4
|
+
import type { CommandRuntimeContext, CommandUndoLogEntry } from '@open-mercato/shared/lib/commands/types'
|
|
5
|
+
import type { CheckoutLink } from '../../data/entities'
|
|
6
|
+
import { createLinkFromSnapshot, restoreLinkFromSnapshot, type CheckoutLinkSnapshot } from '../shared'
|
|
7
|
+
|
|
8
|
+
const ORG_ID = '123e4567-e89b-12d3-a456-426614174000'
|
|
9
|
+
const TENANT_ID = '123e4567-e89b-12d3-a456-426614174001'
|
|
10
|
+
const LINK_ID = '123e4567-e89b-12d3-a456-426614174010'
|
|
11
|
+
|
|
12
|
+
const mockFindOneWithDecryption = jest.fn()
|
|
13
|
+
|
|
14
|
+
jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
|
|
15
|
+
findOneWithDecryption: jest.fn((...args: unknown[]) => mockFindOneWithDecryption(...args)),
|
|
16
|
+
findWithDecryption: jest.fn(async () => []),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
jest.mock('../../lib/gatewayProviderAvailability', () => ({
|
|
20
|
+
ensureGatewayProviderConfigured: jest.fn(async () => undefined),
|
|
21
|
+
getGatewayProviderConfigurationMessageKey: jest.fn(() => null),
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
jest.mock('@open-mercato/shared/lib/commands/helpers', () => ({
|
|
25
|
+
setCustomFieldsIfAny: jest.fn(async () => undefined),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
jest.mock('@open-mercato/shared/lib/commands/customFieldSnapshots', () => ({
|
|
29
|
+
loadCustomFieldSnapshot: jest.fn(async () => ({})),
|
|
30
|
+
buildCustomFieldResetMap: jest.fn(() => ({})),
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
jest.mock('@open-mercato/shared/lib/crud/custom-fields', () => ({
|
|
34
|
+
loadCustomFieldValues: jest.fn(async () => ({})),
|
|
35
|
+
}))
|
|
36
|
+
|
|
37
|
+
jest.mock('../../events', () => ({
|
|
38
|
+
emitCheckoutEvent: jest.fn(async () => undefined),
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
import '../links'
|
|
42
|
+
|
|
43
|
+
type LiveLink = Record<string, unknown> & {
|
|
44
|
+
completionCount: number
|
|
45
|
+
activeReservationCount: number
|
|
46
|
+
isLocked: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function liveLink(overrides: Partial<LiveLink> = {}): LiveLink {
|
|
50
|
+
return {
|
|
51
|
+
id: LINK_ID,
|
|
52
|
+
organizationId: ORG_ID,
|
|
53
|
+
tenantId: TENANT_ID,
|
|
54
|
+
name: 'Pay link',
|
|
55
|
+
title: 'Edited title',
|
|
56
|
+
slug: 'pay-link',
|
|
57
|
+
templateId: null,
|
|
58
|
+
status: 'active',
|
|
59
|
+
pricingMode: 'fixed',
|
|
60
|
+
fixedPriceAmount: '25.00',
|
|
61
|
+
fixedPriceCurrencyCode: 'USD',
|
|
62
|
+
gatewayProviderKey: 'mock',
|
|
63
|
+
gatewaySettings: {},
|
|
64
|
+
maxCompletions: 2,
|
|
65
|
+
completionCount: 1,
|
|
66
|
+
activeReservationCount: 0,
|
|
67
|
+
isLocked: false,
|
|
68
|
+
deletedAt: null,
|
|
69
|
+
...overrides,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function snapshot(overrides: Partial<CheckoutLinkSnapshot> = {}): CheckoutLinkSnapshot {
|
|
74
|
+
return {
|
|
75
|
+
id: LINK_ID,
|
|
76
|
+
organizationId: ORG_ID,
|
|
77
|
+
tenantId: TENANT_ID,
|
|
78
|
+
name: 'Pay link',
|
|
79
|
+
title: 'Original title',
|
|
80
|
+
slug: 'pay-link',
|
|
81
|
+
templateId: null,
|
|
82
|
+
status: 'active',
|
|
83
|
+
pricingMode: 'fixed',
|
|
84
|
+
fixedPriceAmount: 25,
|
|
85
|
+
fixedPriceCurrencyCode: 'USD',
|
|
86
|
+
gatewayProviderKey: 'mock',
|
|
87
|
+
gatewaySettings: {},
|
|
88
|
+
maxCompletions: 2,
|
|
89
|
+
completionCount: 0,
|
|
90
|
+
activeReservationCount: 0,
|
|
91
|
+
isLocked: false,
|
|
92
|
+
passwordHash: null,
|
|
93
|
+
...overrides,
|
|
94
|
+
} as CheckoutLinkSnapshot
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function makeEm(link: LiveLink | null) {
|
|
98
|
+
return {
|
|
99
|
+
findOne: jest.fn(async (_entity: unknown, where: Record<string, unknown>) => (where.slug ? null : link)),
|
|
100
|
+
create: jest.fn((_entity: unknown, data: Record<string, unknown>) => ({ ...data })),
|
|
101
|
+
persist: jest.fn(),
|
|
102
|
+
flush: jest.fn(async () => undefined),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function makeContext(em: unknown): CommandRuntimeContext {
|
|
107
|
+
return {
|
|
108
|
+
container: {
|
|
109
|
+
resolve: (token: string) => {
|
|
110
|
+
if (token === 'em') return em
|
|
111
|
+
if (token === 'dataEngine') return {}
|
|
112
|
+
if (token === 'paymentGatewayDescriptorService') return {}
|
|
113
|
+
if (token === 'commandOptimisticLockGuardService') throw new Error('not registered')
|
|
114
|
+
return null
|
|
115
|
+
},
|
|
116
|
+
} as unknown as CommandRuntimeContext['container'],
|
|
117
|
+
auth: { sub: 'user-1', orgId: ORG_ID, tenantId: TENANT_ID } as CommandRuntimeContext['auth'],
|
|
118
|
+
organizationScope: null,
|
|
119
|
+
selectedOrganizationId: ORG_ID,
|
|
120
|
+
organizationIds: [ORG_ID],
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function logEntry(undo: Record<string, unknown>): CommandUndoLogEntry {
|
|
125
|
+
return { commandPayload: { undo } } as CommandUndoLogEntry
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function runUndo(commandId: string, entry: CommandUndoLogEntry, em: unknown) {
|
|
129
|
+
const handler = commandRegistry.get(commandId)
|
|
130
|
+
if (!handler?.undo) throw new Error(`[internal] ${commandId} has no undo handler`)
|
|
131
|
+
return handler.undo({ input: {}, ctx: makeContext(em), logEntry: entry } as never)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function runRedo(commandId: string, entry: CommandUndoLogEntry, em: unknown) {
|
|
135
|
+
const handler = commandRegistry.get(commandId)
|
|
136
|
+
if (!handler?.redo) throw new Error(`[internal] ${commandId} has no redo handler`)
|
|
137
|
+
return handler.redo({ input: {}, ctx: makeContext(em), logEntry: entry } as never)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
describe('pay-link runtime counters across undo/redo', () => {
|
|
141
|
+
beforeEach(() => {
|
|
142
|
+
jest.clearAllMocks()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('restores configuration from a snapshot but keeps the live counters', () => {
|
|
146
|
+
const target = liveLink({ completionCount: 2, activeReservationCount: 1, isLocked: true })
|
|
147
|
+
|
|
148
|
+
restoreLinkFromSnapshot(target as unknown as CheckoutLink, snapshot())
|
|
149
|
+
|
|
150
|
+
expect(target.title).toBe('Original title')
|
|
151
|
+
expect(target).toMatchObject({ completionCount: 2, activeReservationCount: 1, isLocked: true })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('still seeds the counters when the snapshot becomes a brand-new row', () => {
|
|
155
|
+
expect(createLinkFromSnapshot(snapshot({ completionCount: 1, activeReservationCount: 0, isLocked: false }))).toMatchObject({
|
|
156
|
+
id: LINK_ID,
|
|
157
|
+
completionCount: 1,
|
|
158
|
+
activeReservationCount: 0,
|
|
159
|
+
isLocked: false,
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('undoing an edit keeps payments completed after the edit', async () => {
|
|
164
|
+
const link = liveLink({ completionCount: 2 })
|
|
165
|
+
const em = makeEm(link)
|
|
166
|
+
|
|
167
|
+
await runUndo('checkout.link.update', logEntry({ before: snapshot(), after: snapshot({ title: 'Edited title' }) }), em)
|
|
168
|
+
|
|
169
|
+
expect(em.flush).toHaveBeenCalled()
|
|
170
|
+
expect(link.title).toBe('Original title')
|
|
171
|
+
expect(link).toMatchObject({ completionCount: 2, activeReservationCount: 0, isLocked: false })
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('refuses to undo an edit while the link has payments in flight, like the edit itself', async () => {
|
|
175
|
+
const link = liveLink({ completionCount: 0, activeReservationCount: 1, isLocked: true })
|
|
176
|
+
const em = makeEm(link)
|
|
177
|
+
|
|
178
|
+
await expect(
|
|
179
|
+
runUndo('checkout.link.update', logEntry({ before: snapshot(), after: snapshot({ title: 'Edited title' }) }), em),
|
|
180
|
+
).rejects.toMatchObject({ status: 422 })
|
|
181
|
+
expect(em.flush).not.toHaveBeenCalled()
|
|
182
|
+
expect(link).toMatchObject({ title: 'Edited title', activeReservationCount: 1, isLocked: true })
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('redoing a create keeps the counters of the soft-deleted row it revives', async () => {
|
|
186
|
+
const link = liveLink({ completionCount: 1, deletedAt: new Date() })
|
|
187
|
+
mockFindOneWithDecryption.mockResolvedValueOnce(link)
|
|
188
|
+
const em = makeEm(null)
|
|
189
|
+
|
|
190
|
+
await runRedo('checkout.link.create', logEntry({ after: snapshot() }), em)
|
|
191
|
+
|
|
192
|
+
expect(link.deletedAt).toBeNull()
|
|
193
|
+
expect(link).toMatchObject({ completionCount: 1, activeReservationCount: 0, isLocked: false })
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('undoing a delete keeps the counters of the restored row', async () => {
|
|
197
|
+
const link = liveLink({ completionCount: 2, deletedAt: new Date() })
|
|
198
|
+
const em = makeEm(link)
|
|
199
|
+
|
|
200
|
+
await runUndo('checkout.link.delete', logEntry({ before: snapshot({ completionCount: 1, deletedAt: null } as Partial<CheckoutLinkSnapshot>) }), em)
|
|
201
|
+
|
|
202
|
+
expect(link.deletedAt).toBeNull()
|
|
203
|
+
expect(link).toMatchObject({ completionCount: 2, activeReservationCount: 0, isLocked: false })
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('redoing an edit re-runs it and cannot write the runtime counters', async () => {
|
|
207
|
+
const link = liveLink({ completionCount: 2 })
|
|
208
|
+
mockFindOneWithDecryption.mockResolvedValueOnce(link)
|
|
209
|
+
const em = makeEm(link)
|
|
210
|
+
const handler = commandRegistry.get('checkout.link.update')
|
|
211
|
+
if (!handler) throw new Error('[internal] checkout.link.update not registered')
|
|
212
|
+
|
|
213
|
+
await handler.execute(
|
|
214
|
+
{ id: LINK_ID, title: 'Edited again', completionCount: 0, activeReservationCount: 0, isLocked: false },
|
|
215
|
+
makeContext(em),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
expect(em.flush).toHaveBeenCalled()
|
|
219
|
+
expect(link.title).toBe('Edited again')
|
|
220
|
+
expect(link).toMatchObject({ completionCount: 2, activeReservationCount: 0, isLocked: false })
|
|
221
|
+
})
|
|
222
|
+
})
|
|
@@ -51,7 +51,21 @@ function makeTransaction(status: CheckoutTransaction['status']) {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
type LinkRow = {
|
|
55
|
+
id: string
|
|
56
|
+
organizationId: string
|
|
57
|
+
tenantId: string
|
|
58
|
+
slug: string
|
|
59
|
+
templateId: string | null
|
|
60
|
+
gatewayProviderKey: string
|
|
61
|
+
activeReservationCount: number
|
|
62
|
+
completionCount: number
|
|
63
|
+
maxCompletions: number | null
|
|
64
|
+
isLocked: boolean
|
|
65
|
+
deletedAt: null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function makeLink(overrides: Partial<LinkRow> = {}): LinkRow {
|
|
55
69
|
return {
|
|
56
70
|
id: LINK_ID,
|
|
57
71
|
organizationId: ORG_ID,
|
|
@@ -64,15 +78,20 @@ function makeLink() {
|
|
|
64
78
|
maxCompletions: null,
|
|
65
79
|
isLocked: false,
|
|
66
80
|
deletedAt: null,
|
|
81
|
+
...overrides,
|
|
67
82
|
}
|
|
68
83
|
}
|
|
69
84
|
|
|
85
|
+
type ReleasedLinkRow = { completion_count: number; max_completions: number | null }
|
|
86
|
+
|
|
70
87
|
type MockEm = {
|
|
71
88
|
findOne: jest.Mock
|
|
72
89
|
nativeUpdate: jest.Mock
|
|
90
|
+
execute: jest.Mock
|
|
73
91
|
flush: jest.Mock
|
|
74
92
|
refresh: jest.Mock
|
|
75
93
|
transactional: (fn: (tx: MockEm) => Promise<unknown>) => Promise<unknown>
|
|
94
|
+
link: LinkRow
|
|
76
95
|
}
|
|
77
96
|
|
|
78
97
|
function makeMockEm(
|
|
@@ -81,9 +100,13 @@ function makeMockEm(
|
|
|
81
100
|
// When nativeUpdate returns 0, the post-CAS findOne should return a
|
|
82
101
|
// different status to simulate the winning writer's value.
|
|
83
102
|
postCasStatus?: CheckoutTransaction['status'],
|
|
103
|
+
options: { link?: LinkRow; released?: ReleasedLinkRow[] } = {},
|
|
84
104
|
): MockEm {
|
|
85
105
|
let findOneCallCount = 0
|
|
106
|
+
const link = options.link ?? makeLink()
|
|
107
|
+
const released = options.released ?? [{ completion_count: 1, max_completions: null }]
|
|
86
108
|
const mockTx: MockEm = {
|
|
109
|
+
link,
|
|
87
110
|
findOne: jest.fn(async (_entity: unknown, filter: Record<string, unknown>) => {
|
|
88
111
|
findOneCallCount++
|
|
89
112
|
if (filter.id === TX_ID) {
|
|
@@ -95,10 +118,11 @@ function makeMockEm(
|
|
|
95
118
|
}
|
|
96
119
|
return transaction
|
|
97
120
|
}
|
|
98
|
-
if (filter.id === LINK_ID) return
|
|
121
|
+
if (filter.id === LINK_ID) return link
|
|
99
122
|
return null
|
|
100
123
|
}),
|
|
101
124
|
nativeUpdate: jest.fn(async () => nativeUpdateResult),
|
|
125
|
+
execute: jest.fn(async () => released),
|
|
102
126
|
flush: jest.fn(async () => undefined),
|
|
103
127
|
refresh: jest.fn(async () => undefined),
|
|
104
128
|
transactional: (fn) => fn(mockTx),
|
|
@@ -132,6 +156,10 @@ function baseInput(status: string) {
|
|
|
132
156
|
}
|
|
133
157
|
}
|
|
134
158
|
|
|
159
|
+
function linkCounterUpdateCalls(em: MockEm) {
|
|
160
|
+
return em.execute.mock.calls.filter(([sql]) => typeof sql === 'string' && sql.includes('UPDATE checkout_links'))
|
|
161
|
+
}
|
|
162
|
+
|
|
135
163
|
async function runUpdateStatus(em: MockEm, status: string) {
|
|
136
164
|
const handler = commandRegistry.get('checkout.transaction.updateStatus')
|
|
137
165
|
if (!handler) throw new Error('checkout.transaction.updateStatus not registered')
|
|
@@ -361,3 +389,97 @@ describe('updateTransactionStatusCommand — state-machine guard', () => {
|
|
|
361
389
|
})
|
|
362
390
|
})
|
|
363
391
|
|
|
392
|
+
describe('updateTransactionStatusCommand — pay-link usage counters', () => {
|
|
393
|
+
beforeEach(() => {
|
|
394
|
+
jest.clearAllMocks()
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('processing → completed releases the reservation and counts the completion with one relative UPDATE', async () => {
|
|
398
|
+
const em = makeMockEm(makeTransaction('processing'), 1)
|
|
399
|
+
|
|
400
|
+
await runUpdateStatus(em, 'completed')
|
|
401
|
+
|
|
402
|
+
const calls = linkCounterUpdateCalls(em)
|
|
403
|
+
expect(calls).toHaveLength(1)
|
|
404
|
+
const [sql, params] = calls[0] as [string, unknown[]]
|
|
405
|
+
expect(sql).toMatch(/active_reservation_count = GREATEST\(active_reservation_count - 1, 0\)/)
|
|
406
|
+
expect(sql).toMatch(/completion_count = completion_count \+ \?/)
|
|
407
|
+
expect(sql).toMatch(/is_locked = GREATEST\(active_reservation_count - 1, 0\) > 0/)
|
|
408
|
+
expect(sql).toMatch(/organization_id = \?/)
|
|
409
|
+
expect(sql).toMatch(/tenant_id = \?/)
|
|
410
|
+
expect(sql).toMatch(/deleted_at IS NULL/)
|
|
411
|
+
expect(params).toEqual([1, LINK_ID, ORG_ID, TENANT_ID])
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it.each(['failed', 'cancelled', 'expired'] as const)(
|
|
415
|
+
'processing → %s releases the reservation without counting a completion',
|
|
416
|
+
async (status) => {
|
|
417
|
+
const em = makeMockEm(makeTransaction('processing'), 1, undefined, {
|
|
418
|
+
released: [{ completion_count: 0, max_completions: 1 }],
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
await runUpdateStatus(em, status)
|
|
422
|
+
|
|
423
|
+
const calls = linkCounterUpdateCalls(em)
|
|
424
|
+
expect(calls).toHaveLength(1)
|
|
425
|
+
expect((calls[0] as [string, unknown[]])[1]).toEqual([0, LINK_ID, ORG_ID, TENANT_ID])
|
|
426
|
+
expect(emitCheckoutEvent).not.toHaveBeenCalledWith('checkout.link.usageLimitReached', expect.any(Object))
|
|
427
|
+
},
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
it('never writes counters computed from the link row it read earlier', async () => {
|
|
431
|
+
const em = makeMockEm(makeTransaction('processing'), 1, undefined, {
|
|
432
|
+
link: makeLink({ activeReservationCount: 1, completionCount: 0, isLocked: true }),
|
|
433
|
+
released: [{ completion_count: 3, max_completions: null }],
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
await runUpdateStatus(em, 'completed')
|
|
437
|
+
|
|
438
|
+
expect(em.link).toMatchObject({ activeReservationCount: 1, completionCount: 0, isLocked: true })
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('reports the usage limit from the committed count, not the stale count it read', async () => {
|
|
442
|
+
const em = makeMockEm(makeTransaction('processing'), 1, undefined, {
|
|
443
|
+
link: makeLink({ completionCount: 0, maxCompletions: 2 }),
|
|
444
|
+
released: [{ completion_count: 2, max_completions: 2 }],
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
await runUpdateStatus(em, 'completed')
|
|
448
|
+
|
|
449
|
+
expect(emitCheckoutEvent).toHaveBeenCalledWith('checkout.link.usageLimitReached', {
|
|
450
|
+
id: LINK_ID,
|
|
451
|
+
slug: 'test-link',
|
|
452
|
+
tenantId: TENANT_ID,
|
|
453
|
+
organizationId: ORG_ID,
|
|
454
|
+
})
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it('reports the usage limit only for the completion that reaches it', async () => {
|
|
458
|
+
const em = makeMockEm(makeTransaction('processing'), 1, undefined, {
|
|
459
|
+
link: makeLink({ completionCount: 2, maxCompletions: 2 }),
|
|
460
|
+
released: [{ completion_count: 3, max_completions: 2 }],
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
await runUpdateStatus(em, 'completed')
|
|
464
|
+
|
|
465
|
+
expect(emitCheckoutEvent).toHaveBeenCalledWith('checkout.transaction.completed', expect.any(Object))
|
|
466
|
+
expect(emitCheckoutEvent).not.toHaveBeenCalledWith('checkout.link.usageLimitReached', expect.any(Object))
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it('fails with 404 and emits nothing when the link row is gone by the time the reservation is released', async () => {
|
|
470
|
+
const em = makeMockEm(makeTransaction('processing'), 1, undefined, { released: [] })
|
|
471
|
+
|
|
472
|
+
await expect(runUpdateStatus(em, 'completed')).rejects.toMatchObject({ status: 404 })
|
|
473
|
+
expect(emitCheckoutEvent).not.toHaveBeenCalled()
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it('does not touch the counters on a same-status redelivery or a non-terminal transition', async () => {
|
|
477
|
+
const redelivery = makeMockEm(makeTransaction('completed'), 1)
|
|
478
|
+
await runUpdateStatus(redelivery, 'completed')
|
|
479
|
+
expect(linkCounterUpdateCalls(redelivery)).toHaveLength(0)
|
|
480
|
+
|
|
481
|
+
const nonTerminal = makeMockEm(makeTransaction('pending'), 1)
|
|
482
|
+
await runUpdateStatus(nonTerminal, 'processing')
|
|
483
|
+
expect(linkCounterUpdateCalls(nonTerminal)).toHaveLength(0)
|
|
484
|
+
})
|
|
485
|
+
})
|
|
@@ -392,6 +392,12 @@ const updateLinkCommand: CommandHandler<Record<string, unknown>, { ok: true; slu
|
|
|
392
392
|
const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
|
|
393
393
|
const link = await em.findOne(CheckoutLink, { id: before.id, ...scope, deletedAt: null })
|
|
394
394
|
if (!link) return
|
|
395
|
+
if (link.isLocked) {
|
|
396
|
+
const { translate } = await resolveTranslations()
|
|
397
|
+
throw new CrudHttpError(422, {
|
|
398
|
+
error: translate('checkout.errors.links.lockedEdit', 'This link has active transactions and cannot be edited'),
|
|
399
|
+
})
|
|
400
|
+
}
|
|
395
401
|
restoreLinkFromSnapshot(link, before)
|
|
396
402
|
link.slug = await resolveRestoredLinkSlug(em, before)
|
|
397
403
|
await em.flush()
|
|
@@ -266,6 +266,11 @@ export function restoreTemplateFromSnapshot(
|
|
|
266
266
|
Object.assign(target, buildTemplateSnapshotData(snapshot))
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Restores configuration onto an existing link. The usage counters and lock flag are
|
|
271
|
+
* owned by the link's transactions, so the live row keeps them: rewinding them to a
|
|
272
|
+
* snapshot would drop payments made after it was taken and reopen sold-out links.
|
|
273
|
+
*/
|
|
269
274
|
export function restoreLinkFromSnapshot(
|
|
270
275
|
target: CheckoutLink,
|
|
271
276
|
snapshot: CheckoutLinkSnapshot,
|
|
@@ -274,9 +279,6 @@ export function restoreLinkFromSnapshot(
|
|
|
274
279
|
...buildTemplateSnapshotData(snapshot),
|
|
275
280
|
slug: snapshot.slug,
|
|
276
281
|
templateId: snapshot.templateId ?? null,
|
|
277
|
-
completionCount: snapshot.completionCount,
|
|
278
|
-
activeReservationCount: snapshot.activeReservationCount,
|
|
279
|
-
isLocked: snapshot.isLocked,
|
|
280
282
|
})
|
|
281
283
|
}
|
|
282
284
|
|
|
@@ -2,11 +2,11 @@ import type { EntityManager } from '@mikro-orm/postgresql'
|
|
|
2
2
|
import type { CommandHandler } from '@open-mercato/shared/lib/commands'
|
|
3
3
|
import { registerCommand } from '@open-mercato/shared/lib/commands'
|
|
4
4
|
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
5
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
5
6
|
import { CheckoutLink, CheckoutTransaction } from '../data/entities'
|
|
6
7
|
import { transactionCreateSchema, transactionUpdateStatusSchema } from '../data/validators'
|
|
7
8
|
import { emitCheckoutEvent } from '../events'
|
|
8
9
|
import {
|
|
9
|
-
applyTerminalTransactionState,
|
|
10
10
|
isTerminalCheckoutStatus,
|
|
11
11
|
mapGatewayStatusToCheckoutStatus,
|
|
12
12
|
parseCheckoutInput,
|
|
@@ -64,12 +64,21 @@ const createTransactionCommand: CommandHandler<Record<string, unknown>, { id: st
|
|
|
64
64
|
if (currentLink.status !== 'active') {
|
|
65
65
|
throw new CrudHttpError(422, { error: 'This payment link is not currently accepting payments' })
|
|
66
66
|
}
|
|
67
|
-
const
|
|
67
|
+
const transaction = tx.create(CheckoutTransaction, {
|
|
68
|
+
...parsed,
|
|
69
|
+
organizationId: scope.organizationId,
|
|
70
|
+
tenantId: scope.tenantId,
|
|
71
|
+
amount: toMoneyString(parsed.amount) ?? '0.00',
|
|
72
|
+
status: 'processing',
|
|
73
|
+
})
|
|
74
|
+
tx.persist(transaction)
|
|
75
|
+
await tx.flush()
|
|
76
|
+
const reserved = await tx.execute<Array<{ active_reservation_count: number }>>(
|
|
68
77
|
`
|
|
69
78
|
UPDATE checkout_links
|
|
70
79
|
SET active_reservation_count = active_reservation_count + 1,
|
|
71
80
|
is_locked = true,
|
|
72
|
-
updated_at =
|
|
81
|
+
updated_at = clock_timestamp()
|
|
73
82
|
WHERE id = ?
|
|
74
83
|
AND organization_id = ?
|
|
75
84
|
AND tenant_id = ?
|
|
@@ -79,27 +88,18 @@ const createTransactionCommand: CommandHandler<Record<string, unknown>, { id: st
|
|
|
79
88
|
max_completions IS NULL
|
|
80
89
|
OR completion_count + active_reservation_count < max_completions
|
|
81
90
|
)
|
|
82
|
-
RETURNING
|
|
91
|
+
RETURNING active_reservation_count
|
|
83
92
|
`,
|
|
84
93
|
[parsed.linkId, scope.organizationId, scope.tenantId],
|
|
85
94
|
)
|
|
86
|
-
if (!reserved[0]
|
|
95
|
+
if (!reserved[0]) {
|
|
87
96
|
throw new CrudHttpError(422, { error: 'This payment link is no longer available' })
|
|
88
97
|
}
|
|
89
98
|
lockedLinkId = currentLink.id
|
|
90
99
|
lockedLinkSlug = currentLink.slug
|
|
91
100
|
lockedLinkTemplateId = currentLink.templateId ?? null
|
|
92
101
|
lockedLinkGatewayProvider = currentLink.gatewayProviderKey ?? null
|
|
93
|
-
shouldEmitLockedEvent =
|
|
94
|
-
const transaction = tx.create(CheckoutTransaction, {
|
|
95
|
-
...parsed,
|
|
96
|
-
organizationId: scope.organizationId,
|
|
97
|
-
tenantId: scope.tenantId,
|
|
98
|
-
amount: toMoneyString(parsed.amount) ?? '0.00',
|
|
99
|
-
status: 'processing',
|
|
100
|
-
})
|
|
101
|
-
tx.persist(transaction)
|
|
102
|
-
await tx.flush()
|
|
102
|
+
shouldEmitLockedEvent = Number(reserved[0].active_reservation_count) === 1
|
|
103
103
|
return transaction
|
|
104
104
|
})
|
|
105
105
|
if (shouldEmitLockedEvent && lockedLinkId && lockedLinkSlug) {
|
|
@@ -227,9 +227,33 @@ const updateTransactionStatusCommand: CommandHandler<Record<string, unknown>, {
|
|
|
227
227
|
// status actually changes — prevents double-notification on idempotent
|
|
228
228
|
// redeliveries (e.g. authorized → captured, both mapping to 'completed').
|
|
229
229
|
if (!previousTerminal && nextTerminal) {
|
|
230
|
-
const {
|
|
231
|
-
|
|
232
|
-
|
|
230
|
+
const released = await tx.execute<Array<{ completion_count: number; max_completions: number | null }>>(
|
|
231
|
+
`
|
|
232
|
+
UPDATE checkout_links
|
|
233
|
+
SET active_reservation_count = GREATEST(active_reservation_count - 1, 0),
|
|
234
|
+
completion_count = completion_count + ?,
|
|
235
|
+
is_locked = GREATEST(active_reservation_count - 1, 0) > 0,
|
|
236
|
+
updated_at = clock_timestamp()
|
|
237
|
+
WHERE id = ?
|
|
238
|
+
AND organization_id = ?
|
|
239
|
+
AND tenant_id = ?
|
|
240
|
+
AND deleted_at IS NULL
|
|
241
|
+
RETURNING completion_count, max_completions
|
|
242
|
+
`,
|
|
243
|
+
[nextStatus === 'completed' ? 1 : 0, link.id, scope.organizationId, scope.tenantId],
|
|
244
|
+
)
|
|
245
|
+
if (!released[0]) {
|
|
246
|
+
const { translate } = await resolveTranslations()
|
|
247
|
+
throw new CrudHttpError(404, {
|
|
248
|
+
error: translate('checkout.errors.links.notFound', 'Payment link not found'),
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
const maxCompletions = released[0].max_completions
|
|
252
|
+
if (
|
|
253
|
+
nextStatus === 'completed'
|
|
254
|
+
&& maxCompletions != null
|
|
255
|
+
&& Number(released[0].completion_count) === Number(maxCompletions)
|
|
256
|
+
) {
|
|
233
257
|
emitUsageLimitReached = true
|
|
234
258
|
usageLimitReachedLinkId = link.id
|
|
235
259
|
usageLimitReachedLinkSlug = link.slug
|
|
@@ -152,6 +152,8 @@
|
|
|
152
152
|
"checkout.defaultCustomerFields.lastName.placeholder": "Doe",
|
|
153
153
|
"checkout.defaultCustomerFields.phone.label": "Phone",
|
|
154
154
|
"checkout.defaultCustomerFields.phone.placeholder": "+1 555 123 4567",
|
|
155
|
+
"checkout.errors.links.lockedEdit": "Dieser Zahlungslink hat aktive Transaktionen und kann nicht bearbeitet werden",
|
|
156
|
+
"checkout.errors.links.notFound": "Zahlungslink nicht gefunden",
|
|
155
157
|
"checkout.gatewaySettings.notices.chooseProvider": "Choose a gateway provider to configure checkout session settings.",
|
|
156
158
|
"checkout.gatewaySettings.notices.noSettings": "This provider does not expose additional session settings.",
|
|
157
159
|
"checkout.gatewaySettings.selectPlaceholder": "Select…",
|
|
@@ -152,6 +152,8 @@
|
|
|
152
152
|
"checkout.defaultCustomerFields.lastName.placeholder": "Doe",
|
|
153
153
|
"checkout.defaultCustomerFields.phone.label": "Phone",
|
|
154
154
|
"checkout.defaultCustomerFields.phone.placeholder": "+1 555 123 4567",
|
|
155
|
+
"checkout.errors.links.lockedEdit": "This link has active transactions and cannot be edited",
|
|
156
|
+
"checkout.errors.links.notFound": "Payment link not found",
|
|
155
157
|
"checkout.gatewaySettings.notices.chooseProvider": "Choose a gateway provider to configure checkout session settings.",
|
|
156
158
|
"checkout.gatewaySettings.notices.noSettings": "This provider does not expose additional session settings.",
|
|
157
159
|
"checkout.gatewaySettings.selectPlaceholder": "Select…",
|
|
@@ -152,6 +152,8 @@
|
|
|
152
152
|
"checkout.defaultCustomerFields.lastName.placeholder": "Doe",
|
|
153
153
|
"checkout.defaultCustomerFields.phone.label": "Phone",
|
|
154
154
|
"checkout.defaultCustomerFields.phone.placeholder": "+1 555 123 4567",
|
|
155
|
+
"checkout.errors.links.lockedEdit": "Este enlace de pago tiene transacciones activas y no se puede editar",
|
|
156
|
+
"checkout.errors.links.notFound": "No se encontro el enlace de pago",
|
|
155
157
|
"checkout.gatewaySettings.notices.chooseProvider": "Choose a gateway provider to configure checkout session settings.",
|
|
156
158
|
"checkout.gatewaySettings.notices.noSettings": "This provider does not expose additional session settings.",
|
|
157
159
|
"checkout.gatewaySettings.selectPlaceholder": "Select…",
|
|
@@ -152,6 +152,8 @@
|
|
|
152
152
|
"checkout.defaultCustomerFields.lastName.placeholder": "Doe",
|
|
153
153
|
"checkout.defaultCustomerFields.phone.label": "전화번호",
|
|
154
154
|
"checkout.defaultCustomerFields.phone.placeholder": "+1 555 123 4567",
|
|
155
|
+
"checkout.errors.links.lockedEdit": "이 결제 링크에는 활성 거래가 있어 편집할 수 없습니다",
|
|
156
|
+
"checkout.errors.links.notFound": "결제 링크를 찾을 수 없습니다",
|
|
155
157
|
"checkout.gatewaySettings.notices.chooseProvider": "체크아웃 세션 설정을 구성하려면 게이트웨이 제공업체를 선택하세요.",
|
|
156
158
|
"checkout.gatewaySettings.notices.noSettings": "이 제공업체는 추가 세션 설정을 제공하지 않습니다.",
|
|
157
159
|
"checkout.gatewaySettings.selectPlaceholder": "선택…",
|
|
@@ -152,6 +152,8 @@
|
|
|
152
152
|
"checkout.defaultCustomerFields.lastName.placeholder": "Kowalski",
|
|
153
153
|
"checkout.defaultCustomerFields.phone.label": "Telefon",
|
|
154
154
|
"checkout.defaultCustomerFields.phone.placeholder": "+48 500 123 456",
|
|
155
|
+
"checkout.errors.links.lockedEdit": "Ten link płatności ma aktywne transakcje i nie można go edytować",
|
|
156
|
+
"checkout.errors.links.notFound": "Nie znaleziono linku płatności",
|
|
155
157
|
"checkout.gatewaySettings.notices.chooseProvider": "Wybierz dostawcę bramki, aby skonfigurować ustawienia sesji checkout.",
|
|
156
158
|
"checkout.gatewaySettings.notices.noSettings": "Ten dostawca nie udostępnia dodatkowych ustawień sesji.",
|
|
157
159
|
"checkout.gatewaySettings.selectPlaceholder": "Wybierz…",
|