@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.
Files changed (24) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/checkout/__integration__/TC-CHKT-046-pay-link-usage-counters.spec.js +107 -0
  3. package/dist/modules/checkout/__integration__/TC-CHKT-046-pay-link-usage-counters.spec.js.map +7 -0
  4. package/dist/modules/checkout/__integration__/helpers/fixtures.js.map +2 -2
  5. package/dist/modules/checkout/commands/links.js +6 -0
  6. package/dist/modules/checkout/commands/links.js.map +2 -2
  7. package/dist/modules/checkout/commands/shared.js +1 -4
  8. package/dist/modules/checkout/commands/shared.js.map +2 -2
  9. package/dist/modules/checkout/commands/transactions.js +38 -18
  10. package/dist/modules/checkout/commands/transactions.js.map +2 -2
  11. package/package.json +5 -5
  12. package/src/modules/checkout/__integration__/TC-CHKT-046-pay-link-usage-counters.spec.ts +136 -0
  13. package/src/modules/checkout/__integration__/helpers/fixtures.ts +1 -0
  14. package/src/modules/checkout/commands/__tests__/create-transaction-reservation.test.ts +124 -0
  15. package/src/modules/checkout/commands/__tests__/link-undo-runtime-counters.test.ts +222 -0
  16. package/src/modules/checkout/commands/__tests__/update-transaction-status.test.ts +124 -2
  17. package/src/modules/checkout/commands/links.ts +6 -0
  18. package/src/modules/checkout/commands/shared.ts +5 -3
  19. package/src/modules/checkout/commands/transactions.ts +42 -18
  20. package/src/modules/checkout/i18n/de.json +2 -0
  21. package/src/modules/checkout/i18n/en.json +2 -0
  22. package/src/modules/checkout/i18n/es.json +2 -0
  23. package/src/modules/checkout/i18n/ko.json +2 -0
  24. 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
- function makeLink() {
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 makeLink()
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 reserved = await tx.getConnection().execute<Array<{ id: string }>>(
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 = now()
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 id
91
+ RETURNING active_reservation_count
83
92
  `,
84
93
  [parsed.linkId, scope.organizationId, scope.tenantId],
85
94
  )
86
- if (!reserved[0]?.id) {
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 = !currentLink.isLocked
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 { usageLimitReached } = applyTerminalTransactionState(link, nextStatus)
231
- await tx.flush()
232
- if (usageLimitReached) {
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…",