@open-mercato/core 0.6.7-develop.6572.1.3d8e83062f → 0.6.7-develop.6574.1.05dcf72ba8

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 (36) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/gateway_payment_operation/index.js +35 -0
  3. package/dist/generated/entities/gateway_payment_operation/index.js.map +7 -0
  4. package/dist/generated/entities.ids.generated.js +1 -0
  5. package/dist/generated/entities.ids.generated.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +18 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/payment_gateways/api/cancel/route.js +2 -1
  9. package/dist/modules/payment_gateways/api/cancel/route.js.map +2 -2
  10. package/dist/modules/payment_gateways/api/capture/route.js +2 -1
  11. package/dist/modules/payment_gateways/api/capture/route.js.map +2 -2
  12. package/dist/modules/payment_gateways/api/refund/route.js +2 -1
  13. package/dist/modules/payment_gateways/api/refund/route.js.map +2 -2
  14. package/dist/modules/payment_gateways/data/entities.js +67 -0
  15. package/dist/modules/payment_gateways/data/entities.js.map +2 -2
  16. package/dist/modules/payment_gateways/data/validators.js +6 -3
  17. package/dist/modules/payment_gateways/data/validators.js.map +2 -2
  18. package/dist/modules/payment_gateways/lib/gateway-service.js +145 -111
  19. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  20. package/dist/modules/payment_gateways/lib/payment-operation-idempotency.js +183 -0
  21. package/dist/modules/payment_gateways/lib/payment-operation-idempotency.js.map +7 -0
  22. package/dist/modules/payment_gateways/migrations/Migration20260709220735_payment_gateways.js +13 -0
  23. package/dist/modules/payment_gateways/migrations/Migration20260709220735_payment_gateways.js.map +7 -0
  24. package/generated/entities/gateway_payment_operation/index.ts +16 -0
  25. package/generated/entities.ids.generated.ts +1 -0
  26. package/generated/entity-fields-registry.ts +18 -0
  27. package/package.json +7 -7
  28. package/src/modules/payment_gateways/api/cancel/route.ts +1 -0
  29. package/src/modules/payment_gateways/api/capture/route.ts +1 -0
  30. package/src/modules/payment_gateways/api/refund/route.ts +1 -0
  31. package/src/modules/payment_gateways/data/entities.ts +59 -0
  32. package/src/modules/payment_gateways/data/validators.ts +3 -0
  33. package/src/modules/payment_gateways/lib/gateway-service.ts +168 -117
  34. package/src/modules/payment_gateways/lib/payment-operation-idempotency.ts +238 -0
  35. package/src/modules/payment_gateways/migrations/.snapshot-open-mercato.json +331 -1
  36. package/src/modules/payment_gateways/migrations/Migration20260709220735_payment_gateways.ts +12 -0
@@ -36,6 +36,7 @@ export type CreateSessionPayload = z.infer<typeof createSessionSchema>
36
36
  export const captureSchema = z.object({
37
37
  transactionId: z.string().uuid(),
38
38
  amount: z.number().positive().optional(),
39
+ operationId: z.string().trim().min(1).max(200).optional(),
39
40
  })
40
41
 
41
42
  export type CapturePayload = z.infer<typeof captureSchema>
@@ -44,6 +45,7 @@ export const refundSchema = z.object({
44
45
  transactionId: z.string().uuid(),
45
46
  amount: z.number().positive().optional(),
46
47
  reason: z.string().max(200).optional(),
48
+ operationId: z.string().trim().min(1).max(200).optional(),
47
49
  })
48
50
 
49
51
  export type RefundPayload = z.infer<typeof refundSchema>
@@ -51,6 +53,7 @@ export type RefundPayload = z.infer<typeof refundSchema>
51
53
  export const cancelSchema = z.object({
52
54
  transactionId: z.string().uuid(),
53
55
  reason: z.string().max(200).optional(),
56
+ operationId: z.string().trim().min(1).max(200).optional(),
54
57
  })
55
58
 
56
59
  export type CancelPayload = z.infer<typeof cancelSchema>
@@ -8,6 +8,7 @@ import {
8
8
  type CaptureResult,
9
9
  type RefundResult,
10
10
  type CancelResult,
11
+ type GatewayAdapter,
11
12
  type GatewayPaymentStatus,
12
13
  type PaymentGatewayPresentationRequest,
13
14
  type UnifiedPaymentStatus,
@@ -20,6 +21,11 @@ import { GatewaySessionInitialization, GatewayTransaction } from '../data/entiti
20
21
  import { canApplyManualAction, isValidTransition, type ManualGatewayAction } from './status-machine'
21
22
  import { emitPaymentGatewayEvent } from '../events'
22
23
  import { readGatewayMetadata, readWebhookLog } from './transaction-fields'
24
+ import {
25
+ completePaymentOperation,
26
+ failPaymentOperation,
27
+ preparePaymentOperation,
28
+ } from './payment-operation-idempotency'
23
29
  import {
24
30
  buildPaymentSessionOperationKey,
25
31
  claimPaymentSessionInitialization,
@@ -46,6 +52,7 @@ function applyAdapterResultStatus(
46
52
  ): boolean {
47
53
  const current = transaction.unifiedStatus as UnifiedPaymentStatus
48
54
  if (resultStatus === current) return false
55
+ if (isValidTransition(resultStatus, current)) return false
49
56
  if (!isValidTransition(current, resultStatus)) {
50
57
  throw conflict(
51
58
  `Gateway returned status "${resultStatus}" which is not a valid transition from "${current}" for ${action}`,
@@ -97,9 +104,10 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
97
104
  async function findTransactionOrThrow(
98
105
  transactionId: string,
99
106
  scope: { organizationId: string; tenantId: string },
107
+ targetEm: EntityManager = em,
100
108
  ): Promise<GatewayTransaction> {
101
109
  const transaction = await findOneWithDecryption(
102
- em,
110
+ targetEm,
103
111
  GatewayTransaction,
104
112
  {
105
113
  id: transactionId,
@@ -176,6 +184,82 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
176
184
  return { adapter, credentials }
177
185
  }
178
186
 
187
+ type ManualOperationResult = CaptureResult | RefundResult | CancelResult
188
+
189
+ async function executeManualOperation<TResult extends ManualOperationResult>(input: {
190
+ action: ManualGatewayAction
191
+ transactionId: string
192
+ operationId?: string
193
+ payload: Record<string, unknown>
194
+ scope: { organizationId: string; tenantId: string }
195
+ invoke: (context: {
196
+ adapter: GatewayAdapter
197
+ credentials: Record<string, unknown>
198
+ transaction: GatewayTransaction
199
+ idempotencyKey: string
200
+ }) => Promise<TResult>
201
+ applyResult: (transaction: GatewayTransaction, result: TResult) => void
202
+ afterCommit: (transaction: GatewayTransaction, result: TResult) => Promise<void>
203
+ }): Promise<TResult> {
204
+ const transaction = await findTransactionOrThrow(input.transactionId, input.scope)
205
+ const prepared = await preparePaymentOperation({
206
+ em,
207
+ transactionId: transaction.id,
208
+ providerKey: transaction.providerKey,
209
+ action: input.action,
210
+ operationId: input.operationId,
211
+ payload: input.payload,
212
+ scope: input.scope,
213
+ assertInitialAllowed: () => assertManualActionAllowed(input.action, transaction),
214
+ })
215
+ if (prepared.kind === 'completed') {
216
+ if (prepared.result.status !== transaction.unifiedStatus) {
217
+ throw conflict(
218
+ `Cannot replay ${input.action} because the payment status has changed since the operation completed`,
219
+ )
220
+ }
221
+ return prepared.result as unknown as TResult
222
+ }
223
+
224
+ try {
225
+ const { adapter, credentials } = await resolveAdapterAndCredentials(
226
+ transaction.providerKey,
227
+ { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
228
+ )
229
+ const result = await input.invoke({
230
+ adapter,
231
+ credentials,
232
+ transaction,
233
+ idempotencyKey: prepared.claim.providerIdempotencyKey,
234
+ })
235
+ const statusChanged = await completePaymentOperation(
236
+ em,
237
+ prepared.claim,
238
+ result as unknown as Record<string, unknown>,
239
+ async (tx) => {
240
+ const current = await findTransactionOrThrow(input.transactionId, input.scope, tx)
241
+ const changed = applyAdapterResultStatus(input.action, current, result.status)
242
+ input.applyResult(current, result)
243
+ return changed
244
+ },
245
+ )
246
+ if (statusChanged) {
247
+ await emitStatusEvent(result.status, {
248
+ transactionId: transaction.id,
249
+ paymentId: transaction.paymentId,
250
+ providerKey: transaction.providerKey,
251
+ organizationId: transaction.organizationId,
252
+ tenantId: transaction.tenantId,
253
+ })
254
+ }
255
+ await input.afterCommit(transaction, result)
256
+ return result
257
+ } catch (error: unknown) {
258
+ await Promise.allSettled([failPaymentOperation(em, prepared.claim)])
259
+ throw error
260
+ }
261
+ }
262
+
179
263
  function createGatewayTransaction(
180
264
  manager: EntityManager,
181
265
  input: CreatePaymentSessionInput,
@@ -391,46 +475,39 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
391
475
  }
392
476
  },
393
477
 
394
- async capturePayment(transactionId: string, amount: number | undefined, scope: { organizationId: string; tenantId: string }): Promise<CaptureResult> {
395
- const transaction = await findTransactionOrThrow(transactionId, scope)
396
- assertManualActionAllowed('capture', transaction)
397
- const { adapter, credentials } = await resolveAdapterAndCredentials(
398
- transaction.providerKey,
399
- { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
400
- )
401
-
402
- const result = await adapter.capture({
403
- sessionId: readProviderSessionId(transaction),
404
- amount,
405
- credentials,
406
- })
407
-
408
- const statusChanged = applyAdapterResultStatus('capture', transaction, result.status)
409
- transaction.gatewayMetadata = { ...readGatewayMetadata(transaction.gatewayMetadata), captureResult: result.providerData }
410
- await em.flush()
411
- if (statusChanged) {
412
- await emitStatusEvent(result.status, {
413
- transactionId: transaction.id,
414
- paymentId: transaction.paymentId,
415
- providerKey: transaction.providerKey,
416
- organizationId: transaction.organizationId,
417
- tenantId: transaction.tenantId,
418
- })
419
- }
420
- await writeTransactionLog(
421
- transaction.providerKey,
422
- { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
423
- transaction.id,
424
- 'info',
425
- 'Payment captured',
426
- {
427
- amount: amount ?? null,
428
- status: result.status,
429
- capturedAmount: result.capturedAmount,
478
+ async capturePayment(
479
+ transactionId: string,
480
+ amount: number | undefined,
481
+ scope: { organizationId: string; tenantId: string },
482
+ operationId?: string,
483
+ ): Promise<CaptureResult> {
484
+ return executeManualOperation({
485
+ action: 'capture',
486
+ transactionId,
487
+ operationId,
488
+ payload: { amount: amount ?? null },
489
+ scope,
490
+ invoke: ({ adapter, credentials, transaction, idempotencyKey }) => adapter.capture({
491
+ sessionId: readProviderSessionId(transaction),
492
+ amount,
493
+ credentials,
494
+ idempotencyKey,
495
+ }),
496
+ applyResult: (transaction, result) => {
497
+ transaction.gatewayMetadata = {
498
+ ...readGatewayMetadata(transaction.gatewayMetadata),
499
+ captureResult: result.providerData,
500
+ }
430
501
  },
431
- )
432
-
433
- return result
502
+ afterCommit: (transaction, result) => writeTransactionLog(
503
+ transaction.providerKey,
504
+ { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
505
+ transaction.id,
506
+ 'info',
507
+ 'Payment captured',
508
+ { amount: amount ?? null, status: result.status, capturedAmount: result.capturedAmount },
509
+ ),
510
+ })
434
511
  },
435
512
 
436
513
  async refundPayment(
@@ -438,93 +515,67 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
438
515
  amount: number | undefined,
439
516
  reason: string | undefined,
440
517
  scope: { organizationId: string; tenantId: string },
518
+ operationId?: string,
441
519
  ): Promise<RefundResult> {
442
- const transaction = await findTransactionOrThrow(transactionId, scope)
443
- assertManualActionAllowed('refund', transaction)
444
- const { adapter, credentials } = await resolveAdapterAndCredentials(
445
- transaction.providerKey,
446
- { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
447
- )
448
-
449
- const result = await adapter.refund({
450
- sessionId: readProviderSessionId(transaction),
451
- amount,
452
- reason,
453
- credentials,
454
- })
455
-
456
- const statusChanged = applyAdapterResultStatus('refund', transaction, result.status)
457
- transaction.gatewayRefundId = result.refundId
458
- transaction.gatewayMetadata = { ...readGatewayMetadata(transaction.gatewayMetadata), refundResult: result.providerData }
459
- await em.flush()
460
- if (statusChanged) {
461
- await emitStatusEvent(result.status, {
462
- transactionId: transaction.id,
463
- paymentId: transaction.paymentId,
464
- providerKey: transaction.providerKey,
465
- organizationId: transaction.organizationId,
466
- tenantId: transaction.tenantId,
467
- })
468
- }
469
- await writeTransactionLog(
470
- transaction.providerKey,
471
- { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
472
- transaction.id,
473
- 'info',
474
- 'Payment refunded',
475
- {
476
- amount: amount ?? null,
477
- reason: reason ?? null,
478
- status: result.status,
479
- refundId: result.refundId,
520
+ return executeManualOperation({
521
+ action: 'refund',
522
+ transactionId,
523
+ operationId,
524
+ payload: { amount: amount ?? null, reason: reason ?? null },
525
+ scope,
526
+ invoke: ({ adapter, credentials, transaction, idempotencyKey }) => adapter.refund({
527
+ sessionId: readProviderSessionId(transaction),
528
+ amount,
529
+ reason,
530
+ credentials,
531
+ idempotencyKey,
532
+ }),
533
+ applyResult: (transaction, result) => {
534
+ transaction.gatewayRefundId = result.refundId
535
+ transaction.gatewayMetadata = {
536
+ ...readGatewayMetadata(transaction.gatewayMetadata),
537
+ refundResult: result.providerData,
538
+ }
480
539
  },
481
- )
482
-
483
- return result
540
+ afterCommit: (transaction, result) => writeTransactionLog(
541
+ transaction.providerKey,
542
+ { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
543
+ transaction.id,
544
+ 'info',
545
+ 'Payment refunded',
546
+ { amount: amount ?? null, reason: reason ?? null, status: result.status, refundId: result.refundId },
547
+ ),
548
+ })
484
549
  },
485
550
 
486
551
  async cancelPayment(
487
552
  transactionId: string,
488
553
  reason: string | undefined,
489
554
  scope: { organizationId: string; tenantId: string },
555
+ operationId?: string,
490
556
  ): Promise<CancelResult> {
491
- const transaction = await findTransactionOrThrow(transactionId, scope)
492
- assertManualActionAllowed('cancel', transaction)
493
- const { adapter, credentials } = await resolveAdapterAndCredentials(
494
- transaction.providerKey,
495
- { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
496
- )
497
-
498
- const result = await adapter.cancel({
499
- sessionId: readProviderSessionId(transaction),
500
- reason,
501
- credentials,
557
+ return executeManualOperation({
558
+ action: 'cancel',
559
+ transactionId,
560
+ operationId,
561
+ payload: { reason: reason ?? null },
562
+ scope,
563
+ invoke: ({ adapter, credentials, transaction, idempotencyKey }) => adapter.cancel({
564
+ sessionId: readProviderSessionId(transaction),
565
+ reason,
566
+ credentials,
567
+ idempotencyKey,
568
+ }),
569
+ applyResult: () => {},
570
+ afterCommit: (transaction, result) => writeTransactionLog(
571
+ transaction.providerKey,
572
+ { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
573
+ transaction.id,
574
+ 'info',
575
+ 'Payment cancelled',
576
+ { reason: reason ?? null, status: result.status },
577
+ ),
502
578
  })
503
-
504
- const statusChanged = applyAdapterResultStatus('cancel', transaction, result.status)
505
- await em.flush()
506
- if (statusChanged) {
507
- await emitStatusEvent(result.status, {
508
- transactionId: transaction.id,
509
- paymentId: transaction.paymentId,
510
- providerKey: transaction.providerKey,
511
- organizationId: transaction.organizationId,
512
- tenantId: transaction.tenantId,
513
- })
514
- }
515
- await writeTransactionLog(
516
- transaction.providerKey,
517
- { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
518
- transaction.id,
519
- 'info',
520
- 'Payment cancelled',
521
- {
522
- reason: reason ?? null,
523
- status: result.status,
524
- },
525
- )
526
-
527
- return result
528
579
  },
529
580
 
530
581
  async getPaymentStatus(transactionId: string, scope: { organizationId: string; tenantId: string }): Promise<GatewayPaymentStatus> {
@@ -0,0 +1,238 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { CrudHttpError, isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'
4
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import { GatewayPaymentOperation } from '../data/entities'
6
+ import type { ManualGatewayAction } from './status-machine'
7
+
8
+ type Scope = { organizationId: string; tenantId: string }
9
+ type OperationStatus = 'in_progress' | 'succeeded' | 'failed'
10
+
11
+ const OPERATION_LEASE_MS = 10 * 60 * 1000
12
+ const OPERATION_UNIQUE_CONSTRAINT = 'gateway_payment_operations_scope_operation_unique'
13
+
14
+ export type ClaimedPaymentOperation = {
15
+ record: GatewayPaymentOperation
16
+ attemptToken: string
17
+ providerIdempotencyKey: string
18
+ }
19
+
20
+ export type PreparedPaymentOperation =
21
+ | { kind: 'completed'; result: Record<string, unknown> }
22
+ | { kind: 'claimed'; claim: ClaimedPaymentOperation }
23
+
24
+ type PreparePaymentOperationInput = {
25
+ em: EntityManager
26
+ transactionId: string
27
+ providerKey: string
28
+ action: ManualGatewayAction
29
+ operationId?: string
30
+ payload: Record<string, unknown>
31
+ scope: Scope
32
+ assertInitialAllowed: () => void
33
+ }
34
+
35
+ function digest(value: unknown): string {
36
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex')
37
+ }
38
+
39
+ function operationConflict(code: string, operationId: string, message: string): CrudHttpError {
40
+ return new CrudHttpError(409, { error: message, code, operationId })
41
+ }
42
+
43
+ async function findOperation(
44
+ em: EntityManager,
45
+ operationId: string,
46
+ scope: Scope,
47
+ ): Promise<GatewayPaymentOperation | null> {
48
+ return findOneWithDecryption(
49
+ em,
50
+ GatewayPaymentOperation,
51
+ {
52
+ operationId,
53
+ organizationId: scope.organizationId,
54
+ tenantId: scope.tenantId,
55
+ },
56
+ undefined,
57
+ scope,
58
+ )
59
+ }
60
+
61
+ function buildOperationIdentity(input: Omit<PreparePaymentOperationInput, 'em' | 'assertInitialAllowed'>) {
62
+ const requestHash = digest({
63
+ transactionId: input.transactionId,
64
+ providerKey: input.providerKey,
65
+ action: input.action,
66
+ payload: input.payload,
67
+ })
68
+ const operationId = input.operationId ?? `auto_${requestHash}`
69
+ const providerIdempotencyKey = `om-pg-${digest({
70
+ tenantId: input.scope.tenantId,
71
+ organizationId: input.scope.organizationId,
72
+ transactionId: input.transactionId,
73
+ action: input.action,
74
+ operationId,
75
+ })}`
76
+ return { operationId, providerIdempotencyKey, requestHash }
77
+ }
78
+
79
+ function claimFields(now: Date) {
80
+ return {
81
+ status: 'in_progress' as OperationStatus,
82
+ attemptToken: randomUUID(),
83
+ leaseExpiresAt: new Date(now.getTime() + OPERATION_LEASE_MS),
84
+ updatedAt: now,
85
+ }
86
+ }
87
+
88
+ async function resolveExistingOperation(
89
+ em: EntityManager,
90
+ existing: GatewayPaymentOperation,
91
+ identity: ReturnType<typeof buildOperationIdentity>,
92
+ ): Promise<PreparedPaymentOperation> {
93
+ if (existing.requestHash !== identity.requestHash) {
94
+ throw operationConflict(
95
+ 'payment_operation_conflict',
96
+ identity.operationId,
97
+ 'Payment operation id was already used with a different request',
98
+ )
99
+ }
100
+ if (existing.providerIdempotencyKey !== identity.providerIdempotencyKey) {
101
+ throw operationConflict(
102
+ 'payment_operation_conflict',
103
+ identity.operationId,
104
+ 'Payment operation id resolved to a different provider request',
105
+ )
106
+ }
107
+ if (existing.status === 'succeeded' && existing.result) {
108
+ return { kind: 'completed', result: existing.result }
109
+ }
110
+
111
+ const now = new Date()
112
+ const stale = existing.status === 'in_progress'
113
+ && existing.leaseExpiresAt instanceof Date
114
+ && existing.leaseExpiresAt <= now
115
+ if (existing.status !== 'failed' && !stale) {
116
+ throw operationConflict(
117
+ 'payment_operation_in_progress',
118
+ identity.operationId,
119
+ 'Payment operation is already in progress',
120
+ )
121
+ }
122
+
123
+ const next = claimFields(now)
124
+ const where = existing.status === 'failed'
125
+ ? { id: existing.id, status: 'failed', attemptToken: existing.attemptToken }
126
+ : { id: existing.id, status: 'in_progress', attemptToken: existing.attemptToken, leaseExpiresAt: { $lt: now } }
127
+ const claimed = await em.nativeUpdate(
128
+ GatewayPaymentOperation,
129
+ where,
130
+ { ...next, attemptCount: existing.attemptCount + 1 },
131
+ )
132
+ if (claimed !== 1) {
133
+ throw operationConflict(
134
+ 'payment_operation_in_progress',
135
+ identity.operationId,
136
+ 'Payment operation is already in progress',
137
+ )
138
+ }
139
+ Object.assign(existing, next, { attemptCount: existing.attemptCount + 1 })
140
+ return {
141
+ kind: 'claimed',
142
+ claim: {
143
+ record: existing,
144
+ attemptToken: next.attemptToken,
145
+ providerIdempotencyKey: existing.providerIdempotencyKey,
146
+ },
147
+ }
148
+ }
149
+
150
+ export async function preparePaymentOperation(
151
+ input: PreparePaymentOperationInput,
152
+ ): Promise<PreparedPaymentOperation> {
153
+ const identity = buildOperationIdentity(input)
154
+ const existing = await findOperation(input.em, identity.operationId, input.scope)
155
+ if (existing) {
156
+ return resolveExistingOperation(input.em, existing, identity)
157
+ }
158
+
159
+ input.assertInitialAllowed()
160
+ const now = new Date()
161
+ const claim = claimFields(now)
162
+ const record = input.em.create(GatewayPaymentOperation, {
163
+ operationId: identity.operationId,
164
+ transactionId: input.transactionId,
165
+ operationType: input.action,
166
+ providerKey: input.providerKey,
167
+ requestHash: identity.requestHash,
168
+ providerIdempotencyKey: identity.providerIdempotencyKey,
169
+ status: claim.status,
170
+ attemptToken: claim.attemptToken,
171
+ attemptCount: 1,
172
+ result: null,
173
+ leaseExpiresAt: claim.leaseExpiresAt,
174
+ organizationId: input.scope.organizationId,
175
+ tenantId: input.scope.tenantId,
176
+ createdAt: now,
177
+ updatedAt: now,
178
+ })
179
+ try {
180
+ await input.em.persist(record).flush()
181
+ return {
182
+ kind: 'claimed',
183
+ claim: {
184
+ record,
185
+ attemptToken: claim.attemptToken,
186
+ providerIdempotencyKey: identity.providerIdempotencyKey,
187
+ },
188
+ }
189
+ } catch (error: unknown) {
190
+ if (!isUniqueViolation(error, OPERATION_UNIQUE_CONSTRAINT) && !isUniqueViolation(error)) {
191
+ throw error
192
+ }
193
+ const winner = await findOperation(input.em, identity.operationId, input.scope)
194
+ if (!winner) throw error
195
+ return resolveExistingOperation(input.em, winner, identity)
196
+ }
197
+ }
198
+
199
+ export async function completePaymentOperation<T extends Record<string, unknown>>(
200
+ em: EntityManager,
201
+ claim: ClaimedPaymentOperation,
202
+ result: T,
203
+ applyResult: (tx: EntityManager) => Promise<boolean>,
204
+ ): Promise<boolean> {
205
+ const statusChanged = await em.transactional(async (tx) => {
206
+ const completed = await tx.nativeUpdate(
207
+ GatewayPaymentOperation,
208
+ { id: claim.record.id, status: 'in_progress', attemptToken: claim.attemptToken },
209
+ { status: 'succeeded', result, leaseExpiresAt: null, updatedAt: new Date() },
210
+ )
211
+ if (completed !== 1) {
212
+ throw operationConflict(
213
+ 'payment_operation_claim_lost',
214
+ claim.record.operationId,
215
+ 'Payment operation claim is no longer active',
216
+ )
217
+ }
218
+ const changed = await applyResult(tx)
219
+ await tx.flush()
220
+ return changed
221
+ })
222
+ Object.assign(claim.record, { status: 'succeeded', result, leaseExpiresAt: null })
223
+ return statusChanged
224
+ }
225
+
226
+ export async function failPaymentOperation(
227
+ em: EntityManager,
228
+ claim: ClaimedPaymentOperation,
229
+ ): Promise<void> {
230
+ const failed = await em.nativeUpdate(
231
+ GatewayPaymentOperation,
232
+ { id: claim.record.id, status: 'in_progress', attemptToken: claim.attemptToken },
233
+ { status: 'failed', leaseExpiresAt: null, updatedAt: new Date() },
234
+ )
235
+ if (failed === 1) {
236
+ Object.assign(claim.record, { status: 'failed', leaseExpiresAt: null })
237
+ }
238
+ }