@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.
- package/.turbo/turbo-build.log +2 -2
- package/dist/generated/entities/gateway_payment_operation/index.js +35 -0
- package/dist/generated/entities/gateway_payment_operation/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +1 -0
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +18 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/payment_gateways/api/cancel/route.js +2 -1
- package/dist/modules/payment_gateways/api/cancel/route.js.map +2 -2
- package/dist/modules/payment_gateways/api/capture/route.js +2 -1
- package/dist/modules/payment_gateways/api/capture/route.js.map +2 -2
- package/dist/modules/payment_gateways/api/refund/route.js +2 -1
- package/dist/modules/payment_gateways/api/refund/route.js.map +2 -2
- package/dist/modules/payment_gateways/data/entities.js +67 -0
- package/dist/modules/payment_gateways/data/entities.js.map +2 -2
- package/dist/modules/payment_gateways/data/validators.js +6 -3
- package/dist/modules/payment_gateways/data/validators.js.map +2 -2
- package/dist/modules/payment_gateways/lib/gateway-service.js +145 -111
- package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
- package/dist/modules/payment_gateways/lib/payment-operation-idempotency.js +183 -0
- package/dist/modules/payment_gateways/lib/payment-operation-idempotency.js.map +7 -0
- package/dist/modules/payment_gateways/migrations/Migration20260709220735_payment_gateways.js +13 -0
- package/dist/modules/payment_gateways/migrations/Migration20260709220735_payment_gateways.js.map +7 -0
- package/generated/entities/gateway_payment_operation/index.ts +16 -0
- package/generated/entities.ids.generated.ts +1 -0
- package/generated/entity-fields-registry.ts +18 -0
- package/package.json +7 -7
- package/src/modules/payment_gateways/api/cancel/route.ts +1 -0
- package/src/modules/payment_gateways/api/capture/route.ts +1 -0
- package/src/modules/payment_gateways/api/refund/route.ts +1 -0
- package/src/modules/payment_gateways/data/entities.ts +59 -0
- package/src/modules/payment_gateways/data/validators.ts +3 -0
- package/src/modules/payment_gateways/lib/gateway-service.ts +168 -117
- package/src/modules/payment_gateways/lib/payment-operation-idempotency.ts +238 -0
- package/src/modules/payment_gateways/migrations/.snapshot-open-mercato.json +331 -1
- 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
|
-
|
|
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(
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
amount,
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
{
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
{
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
+
}
|