@open-mercato/core 0.6.7-develop.6677.1.beabb7ca12 → 0.6.7-develop.6685.1.77c0a5591b
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/generated/entities/gateway_payment_operation/index.js +2 -0
- package/dist/generated/entities/gateway_payment_operation/index.js.map +2 -2
- package/dist/generated/entities/gateway_transaction/index.js +2 -0
- package/dist/generated/entities/gateway_transaction/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +2 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/payment_gateways/api/capture/route.js +1 -1
- package/dist/modules/payment_gateways/api/capture/route.js.map +2 -2
- package/dist/modules/payment_gateways/data/entities.js +7 -0
- package/dist/modules/payment_gateways/data/entities.js.map +2 -2
- package/dist/modules/payment_gateways/lib/capture-ledger.js +183 -0
- package/dist/modules/payment_gateways/lib/capture-ledger.js.map +7 -0
- package/dist/modules/payment_gateways/lib/gateway-service.js +70 -8
- package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
- package/dist/modules/payment_gateways/lib/payment-operation-idempotency.js +1 -0
- package/dist/modules/payment_gateways/lib/payment-operation-idempotency.js.map +2 -2
- package/dist/modules/payment_gateways/migrations/Migration20260725101500_payment_gateways.js +16 -0
- package/dist/modules/payment_gateways/migrations/Migration20260725101500_payment_gateways.js.map +7 -0
- package/dist/modules/query_index/lib/indexer.js +12 -2
- package/dist/modules/query_index/lib/indexer.js.map +2 -2
- package/dist/modules/workflows/backend/definitions/visual-editor/page.js +5 -2
- package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
- package/dist/modules/workflows/components/ActivitiesEditor.js +67 -11
- package/dist/modules/workflows/components/ActivitiesEditor.js.map +2 -2
- package/dist/modules/workflows/components/TransitionsEditor.js +205 -163
- package/dist/modules/workflows/components/TransitionsEditor.js.map +2 -2
- package/dist/modules/workflows/data/validators.js +34 -1
- package/dist/modules/workflows/data/validators.js.map +3 -3
- package/dist/modules/workflows/lib/activity-executor.js +18 -2
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/dist/modules/workflows/lib/format-validation-error.js +46 -1
- package/dist/modules/workflows/lib/format-validation-error.js.map +2 -2
- package/generated/entities/gateway_payment_operation/index.ts +1 -0
- package/generated/entities/gateway_transaction/index.ts +1 -0
- package/generated/entity-fields-registry.ts +2 -0
- package/package.json +7 -7
- package/src/modules/payment_gateways/api/capture/route.ts +1 -1
- package/src/modules/payment_gateways/data/entities.ts +8 -2
- package/src/modules/payment_gateways/lib/capture-ledger.ts +260 -0
- package/src/modules/payment_gateways/lib/gateway-service.ts +83 -10
- package/src/modules/payment_gateways/lib/payment-operation-idempotency.ts +1 -0
- package/src/modules/payment_gateways/migrations/.snapshot-open-mercato.json +34 -0
- package/src/modules/payment_gateways/migrations/Migration20260725101500_payment_gateways.ts +21 -0
- package/src/modules/query_index/lib/indexer.ts +25 -2
- package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +12 -3
- package/src/modules/workflows/components/ActivitiesEditor.tsx +96 -12
- package/src/modules/workflows/components/TransitionsEditor.tsx +75 -17
- package/src/modules/workflows/data/validators.ts +52 -1
- package/src/modules/workflows/i18n/de.json +2 -0
- package/src/modules/workflows/i18n/en.json +2 -0
- package/src/modules/workflows/i18n/es.json +2 -0
- package/src/modules/workflows/i18n/pl.json +2 -0
- package/src/modules/workflows/lib/activity-executor.ts +38 -3
- package/src/modules/workflows/lib/format-validation-error.ts +60 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
3
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
4
|
+
import type { UnifiedPaymentStatus } from '@open-mercato/shared/modules/payment_gateways/types'
|
|
5
|
+
import { GatewayPaymentOperation, GatewayTransaction } from '../data/entities'
|
|
6
|
+
|
|
7
|
+
type Scope = { organizationId: string; tenantId: string }
|
|
8
|
+
|
|
9
|
+
const AMOUNT_SCALE = 4
|
|
10
|
+
const AMOUNT_UNITS_PER_WHOLE = 10n ** BigInt(AMOUNT_SCALE)
|
|
11
|
+
const DECIMAL_PATTERN = /^(-?)(\d*)(?:\.(\d*))?$/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Money in this module lives in `numeric(18,4)` columns and is read back as strings. Every
|
|
15
|
+
* comparison and every sum runs on integer minor units (1 unit = 10^-4) so partial captures
|
|
16
|
+
* never accumulate floating-point drift. Extra precision beyond four decimals is rounded
|
|
17
|
+
* half-up, matching what Postgres would store in the column anyway.
|
|
18
|
+
*/
|
|
19
|
+
export function parseAmountUnits(value: string | number | null | undefined): bigint {
|
|
20
|
+
if (value === null || value === undefined) return 0n
|
|
21
|
+
const text = typeof value === 'number' ? value.toFixed(AMOUNT_SCALE) : value.trim()
|
|
22
|
+
const match = DECIMAL_PATTERN.exec(text)
|
|
23
|
+
const whole = match?.[2] ?? ''
|
|
24
|
+
const fraction = match?.[3] ?? ''
|
|
25
|
+
if (!match || (whole === '' && fraction === '')) {
|
|
26
|
+
throw new Error(`[internal] Unsupported gateway amount value: ${String(value)}`)
|
|
27
|
+
}
|
|
28
|
+
const keptFraction = fraction.slice(0, AMOUNT_SCALE).padEnd(AMOUNT_SCALE, '0')
|
|
29
|
+
const nextDigit = fraction.charAt(AMOUNT_SCALE)
|
|
30
|
+
let units = BigInt(whole === '' ? '0' : whole) * AMOUNT_UNITS_PER_WHOLE + BigInt(keptFraction)
|
|
31
|
+
if (nextDigit !== '' && Number(nextDigit) >= 5) units += 1n
|
|
32
|
+
return match[1] === '-' ? -units : units
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatAmountUnits(units: bigint): string {
|
|
36
|
+
const negative = units < 0n
|
|
37
|
+
const absolute = negative ? -units : units
|
|
38
|
+
const fraction = (absolute % AMOUNT_UNITS_PER_WHOLE).toString().padStart(AMOUNT_SCALE, '0')
|
|
39
|
+
return `${negative ? '-' : ''}${absolute / AMOUNT_UNITS_PER_WHOLE}.${fraction}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatAmountLabel(units: bigint): string {
|
|
43
|
+
return formatAmountUnits(units).replace(/\.?0+$/, '')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function captureConflict(code: string, message: string): CrudHttpError {
|
|
47
|
+
return new CrudHttpError(409, { error: message, code })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type CaptureAmounts = {
|
|
51
|
+
authorizedUnits: bigint
|
|
52
|
+
capturedUnits: bigint
|
|
53
|
+
remainingUnits: bigint
|
|
54
|
+
requestedUnits: bigint
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolves what a capture request means for the transaction's running total. An omitted
|
|
59
|
+
* amount captures everything that is still authorized, not the original full amount.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveCaptureAmounts(transaction: GatewayTransaction, amount: number | undefined): CaptureAmounts {
|
|
62
|
+
const authorizedUnits = parseAmountUnits(transaction.amount)
|
|
63
|
+
const capturedUnits = parseAmountUnits(transaction.capturedAmount)
|
|
64
|
+
const remainingUnits = authorizedUnits - capturedUnits
|
|
65
|
+
return {
|
|
66
|
+
authorizedUnits,
|
|
67
|
+
capturedUnits,
|
|
68
|
+
remainingUnits,
|
|
69
|
+
requestedUnits: amount === undefined ? remainingUnits : parseAmountUnits(amount),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Rejects a capture whose amount would push the captured-to-date total past the authorized
|
|
75
|
+
* amount. This is the cumulative ceiling: each individual request may look small, but the
|
|
76
|
+
* sum of all captures against one authorization can never exceed it (#4487).
|
|
77
|
+
*/
|
|
78
|
+
export function assertCaptureWithinRemaining(
|
|
79
|
+
transaction: GatewayTransaction,
|
|
80
|
+
amount: number | undefined,
|
|
81
|
+
): CaptureAmounts {
|
|
82
|
+
const amounts = resolveCaptureAmounts(transaction, amount)
|
|
83
|
+
if (amounts.remainingUnits <= 0n) {
|
|
84
|
+
throw captureConflict(
|
|
85
|
+
'payment_capture_ceiling_exceeded',
|
|
86
|
+
`Transaction is already fully captured (${formatAmountLabel(amounts.capturedUnits)} of ${formatAmountLabel(amounts.authorizedUnits)})`,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
if (amounts.requestedUnits <= 0n) {
|
|
90
|
+
throw captureConflict('payment_capture_amount_invalid', 'Capture amount must be greater than zero')
|
|
91
|
+
}
|
|
92
|
+
if (amounts.requestedUnits > amounts.remainingUnits) {
|
|
93
|
+
throw captureConflict(
|
|
94
|
+
'payment_capture_ceiling_exceeded',
|
|
95
|
+
`Capture amount ${formatAmountLabel(amounts.requestedUnits)} exceeds the ${formatAmountLabel(amounts.remainingUnits)} still capturable on authorized amount ${formatAmountLabel(amounts.authorizedUnits)} (already captured ${formatAmountLabel(amounts.capturedUnits)})`,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
return amounts
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Claims the requested slice of the authorized amount before the provider is called, so two
|
|
103
|
+
* concurrent captures can never both charge. The increment is a compare-and-swap on the
|
|
104
|
+
* previous captured-to-date value: whoever loses it never reaches the adapter. The reserved
|
|
105
|
+
* amount is stamped on the operation row in the same transaction, so a retry of the same
|
|
106
|
+
* operation id reuses its reservation instead of reserving twice.
|
|
107
|
+
*/
|
|
108
|
+
export async function reserveCaptureAmount(em: EntityManager, input: {
|
|
109
|
+
transaction: GatewayTransaction
|
|
110
|
+
operation: GatewayPaymentOperation
|
|
111
|
+
amount: number | undefined
|
|
112
|
+
scope: Scope
|
|
113
|
+
}): Promise<bigint> {
|
|
114
|
+
if (input.operation.reservedAmount !== null && input.operation.reservedAmount !== undefined) {
|
|
115
|
+
return parseAmountUnits(input.operation.reservedAmount)
|
|
116
|
+
}
|
|
117
|
+
const amounts = assertCaptureWithinRemaining(input.transaction, input.amount)
|
|
118
|
+
const previousCapturedAmount = input.transaction.capturedAmount
|
|
119
|
+
const reservedAmount = formatAmountUnits(amounts.requestedUnits)
|
|
120
|
+
const nextCapturedAmount = formatAmountUnits(amounts.capturedUnits + amounts.requestedUnits)
|
|
121
|
+
|
|
122
|
+
await em.transactional(async (tx) => {
|
|
123
|
+
const reserved = await tx.nativeUpdate(
|
|
124
|
+
GatewayTransaction,
|
|
125
|
+
{
|
|
126
|
+
id: input.transaction.id,
|
|
127
|
+
organizationId: input.scope.organizationId,
|
|
128
|
+
tenantId: input.scope.tenantId,
|
|
129
|
+
deletedAt: null,
|
|
130
|
+
capturedAmount: previousCapturedAmount,
|
|
131
|
+
},
|
|
132
|
+
{ capturedAmount: nextCapturedAmount, updatedAt: new Date() },
|
|
133
|
+
)
|
|
134
|
+
if (reserved !== 1) {
|
|
135
|
+
throw captureConflict(
|
|
136
|
+
'payment_capture_reservation_conflict',
|
|
137
|
+
'This transaction changed while the capture amount was being reserved; re-read it and retry with the remaining amount',
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
const stamped = await tx.nativeUpdate(
|
|
141
|
+
GatewayPaymentOperation,
|
|
142
|
+
{
|
|
143
|
+
id: input.operation.id,
|
|
144
|
+
organizationId: input.scope.organizationId,
|
|
145
|
+
tenantId: input.scope.tenantId,
|
|
146
|
+
reservedAmount: null,
|
|
147
|
+
},
|
|
148
|
+
{ reservedAmount, updatedAt: new Date() },
|
|
149
|
+
)
|
|
150
|
+
if (stamped !== 1) {
|
|
151
|
+
throw captureConflict(
|
|
152
|
+
'payment_capture_reservation_conflict',
|
|
153
|
+
'Capture reservation could not be recorded for this operation',
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
Object.assign(input.operation, { reservedAmount })
|
|
159
|
+
return amounts.requestedUnits
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Reconciles the reservation with what the provider actually captured. Runs on the transaction
|
|
164
|
+
* instance loaded inside the completion transaction, so the adjustment commits together with
|
|
165
|
+
* the operation result.
|
|
166
|
+
*/
|
|
167
|
+
export function settleCapturedAmount(
|
|
168
|
+
transaction: GatewayTransaction,
|
|
169
|
+
reservedUnits: bigint,
|
|
170
|
+
capturedAmount: number | undefined,
|
|
171
|
+
): void {
|
|
172
|
+
const actualUnits = typeof capturedAmount === 'number' && Number.isFinite(capturedAmount) && capturedAmount >= 0
|
|
173
|
+
? parseAmountUnits(capturedAmount)
|
|
174
|
+
: reservedUnits
|
|
175
|
+
if (actualUnits === reservedUnits) return
|
|
176
|
+
const settledUnits = parseAmountUnits(transaction.capturedAmount) - reservedUnits + actualUnits
|
|
177
|
+
transaction.capturedAmount = formatAmountUnits(settledUnits < 0n ? 0n : settledUnits)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Keeps the ledger honest for captures that happened outside the capture endpoint. A webhook or a
|
|
182
|
+
* status poll reports money that already moved but carries no captured amount, so only `captured`
|
|
183
|
+
* — the provider saying the whole authorization was taken — can be translated into a number: the
|
|
184
|
+
* full amount. The ledger is raised, never lowered, so an already recorded capture survives.
|
|
185
|
+
* `partially_captured` carries no recoverable amount, so it is left alone and the remainder stays
|
|
186
|
+
* capturable.
|
|
187
|
+
*/
|
|
188
|
+
export function alignCapturedAmountWithStatus(
|
|
189
|
+
transaction: GatewayTransaction,
|
|
190
|
+
status: UnifiedPaymentStatus,
|
|
191
|
+
): void {
|
|
192
|
+
if (status !== 'captured') return
|
|
193
|
+
const authorizedUnits = parseAmountUnits(transaction.amount)
|
|
194
|
+
if (parseAmountUnits(transaction.capturedAmount) >= authorizedUnits) return
|
|
195
|
+
transaction.capturedAmount = formatAmountUnits(authorizedUnits)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Gives the reserved slice back after a failed provider call. Both writes share one
|
|
200
|
+
* transaction, so a partial release is impossible: either the amount becomes capturable again
|
|
201
|
+
* and the operation stops holding it, or the reservation stays outstanding. Returning `false`
|
|
202
|
+
* means the reservation is still held — the conservative outcome when it is unknown whether
|
|
203
|
+
* the provider took the money.
|
|
204
|
+
*/
|
|
205
|
+
export async function releaseCaptureAmount(em: EntityManager, input: {
|
|
206
|
+
transactionId: string
|
|
207
|
+
operation: GatewayPaymentOperation
|
|
208
|
+
reservedUnits: bigint
|
|
209
|
+
scope: Scope
|
|
210
|
+
}): Promise<boolean> {
|
|
211
|
+
const reservedAmount = formatAmountUnits(input.reservedUnits)
|
|
212
|
+
try {
|
|
213
|
+
return await em.transactional(async (tx) => {
|
|
214
|
+
const current = await findOneWithDecryption(
|
|
215
|
+
tx,
|
|
216
|
+
GatewayTransaction,
|
|
217
|
+
{
|
|
218
|
+
id: input.transactionId,
|
|
219
|
+
organizationId: input.scope.organizationId,
|
|
220
|
+
tenantId: input.scope.tenantId,
|
|
221
|
+
},
|
|
222
|
+
undefined,
|
|
223
|
+
input.scope,
|
|
224
|
+
)
|
|
225
|
+
if (!current) return false
|
|
226
|
+
const releasedUnits = parseAmountUnits(current.capturedAmount) - input.reservedUnits
|
|
227
|
+
const released = await tx.nativeUpdate(
|
|
228
|
+
GatewayTransaction,
|
|
229
|
+
{
|
|
230
|
+
id: input.transactionId,
|
|
231
|
+
organizationId: input.scope.organizationId,
|
|
232
|
+
tenantId: input.scope.tenantId,
|
|
233
|
+
capturedAmount: current.capturedAmount,
|
|
234
|
+
},
|
|
235
|
+
{ capturedAmount: formatAmountUnits(releasedUnits < 0n ? 0n : releasedUnits), updatedAt: new Date() },
|
|
236
|
+
)
|
|
237
|
+
if (released !== 1) return false
|
|
238
|
+
const cleared = await tx.nativeUpdate(
|
|
239
|
+
GatewayPaymentOperation,
|
|
240
|
+
{
|
|
241
|
+
id: input.operation.id,
|
|
242
|
+
organizationId: input.scope.organizationId,
|
|
243
|
+
tenantId: input.scope.tenantId,
|
|
244
|
+
reservedAmount,
|
|
245
|
+
},
|
|
246
|
+
{ reservedAmount: null, updatedAt: new Date() },
|
|
247
|
+
)
|
|
248
|
+
if (cleared !== 1) {
|
|
249
|
+
throw captureConflict(
|
|
250
|
+
'payment_capture_reservation_conflict',
|
|
251
|
+
'Capture reservation could not be released for this operation',
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
Object.assign(input.operation, { reservedAmount: null })
|
|
255
|
+
return true
|
|
256
|
+
})
|
|
257
|
+
} catch {
|
|
258
|
+
return false
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -17,10 +17,19 @@ import type { CredentialsService } from '../../integrations/lib/credentials-serv
|
|
|
17
17
|
import type { IntegrationStateService } from '../../integrations/lib/state-service'
|
|
18
18
|
import type { IntegrationLogService } from '../../integrations/lib/log-service'
|
|
19
19
|
import { conflict } from '@open-mercato/shared/lib/crud/errors'
|
|
20
|
-
import { GatewaySessionInitialization, GatewayTransaction } from '../data/entities'
|
|
20
|
+
import { GatewayPaymentOperation, GatewaySessionInitialization, GatewayTransaction } from '../data/entities'
|
|
21
21
|
import { canApplyManualAction, isValidTransition, type ManualGatewayAction } from './status-machine'
|
|
22
22
|
import { emitPaymentGatewayEvent } from '../events'
|
|
23
23
|
import { readGatewayMetadata, readWebhookLog } from './transaction-fields'
|
|
24
|
+
import {
|
|
25
|
+
alignCapturedAmountWithStatus,
|
|
26
|
+
assertCaptureWithinRemaining,
|
|
27
|
+
formatAmountUnits,
|
|
28
|
+
parseAmountUnits,
|
|
29
|
+
releaseCaptureAmount,
|
|
30
|
+
reserveCaptureAmount,
|
|
31
|
+
settleCapturedAmount,
|
|
32
|
+
} from './capture-ledger'
|
|
24
33
|
import {
|
|
25
34
|
completePaymentOperation,
|
|
26
35
|
failPaymentOperation,
|
|
@@ -45,12 +54,6 @@ function assertManualActionAllowed(action: ManualGatewayAction, transaction: Gat
|
|
|
45
54
|
}
|
|
46
55
|
}
|
|
47
56
|
|
|
48
|
-
function assertCaptureAmountAllowed(amount: number | undefined, transaction: GatewayTransaction): void {
|
|
49
|
-
if (amount !== undefined && amount > Number(transaction.amount)) {
|
|
50
|
-
throw conflict(`Capture amount ${amount} exceeds authorized transaction amount ${transaction.amount}`)
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
57
|
function applyAdapterResultStatus(
|
|
55
58
|
action: ManualGatewayAction,
|
|
56
59
|
transaction: GatewayTransaction,
|
|
@@ -199,6 +202,12 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
|
|
|
199
202
|
payload: Record<string, unknown>
|
|
200
203
|
scope: { organizationId: string; tenantId: string }
|
|
201
204
|
assertInitialAllowed?: (transaction: GatewayTransaction) => void
|
|
205
|
+
beforeInvoke?: (context: { transaction: GatewayTransaction; operation: GatewayPaymentOperation }) => Promise<void>
|
|
206
|
+
releaseOnFailure?: (context: {
|
|
207
|
+
transaction: GatewayTransaction
|
|
208
|
+
operation: GatewayPaymentOperation
|
|
209
|
+
providerInvoked: boolean
|
|
210
|
+
}) => Promise<void>
|
|
202
211
|
invoke: (context: {
|
|
203
212
|
adapter: GatewayAdapter
|
|
204
213
|
credentials: Record<string, unknown>
|
|
@@ -231,7 +240,17 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
|
|
|
231
240
|
return prepared.result as unknown as TResult
|
|
232
241
|
}
|
|
233
242
|
|
|
243
|
+
const operation = prepared.claim.record
|
|
244
|
+
// Everything after this flips to true is post-settlement: the provider moved the money and the
|
|
245
|
+
// completion transaction committed it. A later event or logging failure must not undo either,
|
|
246
|
+
// so the failure path below only runs while the operation can still be rolled back.
|
|
247
|
+
let settled = false
|
|
248
|
+
// Flips once the provider call returned, so the failure path can tell "the provider never
|
|
249
|
+
// moved the money" from "the provider moved it and only our bookkeeping failed" — the two
|
|
250
|
+
// cases need opposite rollback decisions.
|
|
251
|
+
let providerInvoked = false
|
|
234
252
|
try {
|
|
253
|
+
await input.beforeInvoke?.({ transaction, operation })
|
|
235
254
|
const { adapter, credentials } = await resolveAdapterAndCredentials(
|
|
236
255
|
transaction.providerKey,
|
|
237
256
|
{ organizationId: transaction.organizationId, tenantId: transaction.tenantId },
|
|
@@ -242,6 +261,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
|
|
|
242
261
|
transaction,
|
|
243
262
|
idempotencyKey: prepared.claim.providerIdempotencyKey,
|
|
244
263
|
})
|
|
264
|
+
providerInvoked = true
|
|
245
265
|
const statusChanged = await completePaymentOperation(
|
|
246
266
|
em,
|
|
247
267
|
prepared.claim,
|
|
@@ -253,6 +273,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
|
|
|
253
273
|
return changed
|
|
254
274
|
},
|
|
255
275
|
)
|
|
276
|
+
settled = true
|
|
256
277
|
if (statusChanged) {
|
|
257
278
|
await emitStatusEvent(result.status, {
|
|
258
279
|
transactionId: transaction.id,
|
|
@@ -265,7 +286,12 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
|
|
|
265
286
|
await input.afterCommit(transaction, result)
|
|
266
287
|
return result
|
|
267
288
|
} catch (error: unknown) {
|
|
268
|
-
|
|
289
|
+
if (!settled) {
|
|
290
|
+
if (input.releaseOnFailure) {
|
|
291
|
+
await input.releaseOnFailure({ transaction, operation, providerInvoked }).catch(() => undefined)
|
|
292
|
+
}
|
|
293
|
+
await Promise.allSettled([failPaymentOperation(em, prepared.claim)])
|
|
294
|
+
}
|
|
269
295
|
throw error
|
|
270
296
|
}
|
|
271
297
|
}
|
|
@@ -285,6 +311,9 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
|
|
|
285
311
|
?? (session.clientSession?.type === 'redirect' ? session.clientSession.redirectUrl : null),
|
|
286
312
|
clientSecret: session.clientSecret ?? null,
|
|
287
313
|
amount: String(input.amount),
|
|
314
|
+
// An automatic-capture session comes back already captured, so the ledger has to start
|
|
315
|
+
// at the full amount — otherwise a later manual capture would look like the first one.
|
|
316
|
+
capturedAmount: session.status === 'captured' ? String(input.amount) : '0',
|
|
288
317
|
currencyCode: input.currencyCode,
|
|
289
318
|
gatewayMetadata: {
|
|
290
319
|
...(session.providerData ?? {}),
|
|
@@ -491,16 +520,57 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
|
|
|
491
520
|
scope: { organizationId: string; tenantId: string },
|
|
492
521
|
operationId?: string,
|
|
493
522
|
): Promise<CaptureResult> {
|
|
523
|
+
let reservedUnits: bigint | null = null
|
|
524
|
+
let providerAmount = amount
|
|
494
525
|
return executeManualOperation({
|
|
495
526
|
action: 'capture',
|
|
496
527
|
transactionId,
|
|
497
528
|
operationId,
|
|
498
529
|
payload: { amount: amount ?? null },
|
|
499
530
|
scope,
|
|
500
|
-
assertInitialAllowed: (transaction) =>
|
|
531
|
+
assertInitialAllowed: (transaction) => { assertCaptureWithinRemaining(transaction, amount) },
|
|
532
|
+
beforeInvoke: async ({ transaction, operation }) => {
|
|
533
|
+
const alreadyCapturedUnits = parseAmountUnits(transaction.capturedAmount)
|
|
534
|
+
reservedUnits = await reserveCaptureAmount(em, { transaction, operation, amount, scope })
|
|
535
|
+
// "Capture the rest" has to name the remaining amount once part of the authorization is
|
|
536
|
+
// already captured, otherwise the provider would capture the full amount a second time.
|
|
537
|
+
if (amount === undefined && alreadyCapturedUnits > 0n) {
|
|
538
|
+
providerAmount = Number(formatAmountUnits(reservedUnits))
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
// The slice goes back only while the provider has not been called yet. Once the capture
|
|
542
|
+
// call returned, the money may already have moved and only the completion transaction
|
|
543
|
+
// failed, so releasing would reopen the authorization for a fresh operation id and allow an
|
|
544
|
+
// over-capture. Holding the reservation is the conservative outcome: a retry of this same
|
|
545
|
+
// operation id reuses both the reservation and the provider idempotency key, so the provider
|
|
546
|
+
// itself collapses the duplicate.
|
|
547
|
+
releaseOnFailure: async ({ transaction, operation, providerInvoked }) => {
|
|
548
|
+
if (reservedUnits === null) return
|
|
549
|
+
if (providerInvoked) {
|
|
550
|
+
await writeTransactionLog(
|
|
551
|
+
transaction.providerKey,
|
|
552
|
+
{ organizationId: transaction.organizationId, tenantId: transaction.tenantId },
|
|
553
|
+
transaction.id,
|
|
554
|
+
'warn',
|
|
555
|
+
'Capture reservation stays outstanding because the provider call already returned',
|
|
556
|
+
{ operationId: operation.operationId, reservedAmount: formatAmountUnits(reservedUnits) },
|
|
557
|
+
)
|
|
558
|
+
return
|
|
559
|
+
}
|
|
560
|
+
const released = await releaseCaptureAmount(em, { transactionId, operation, reservedUnits, scope })
|
|
561
|
+
if (released) return
|
|
562
|
+
await writeTransactionLog(
|
|
563
|
+
transaction.providerKey,
|
|
564
|
+
{ organizationId: transaction.organizationId, tenantId: transaction.tenantId },
|
|
565
|
+
transaction.id,
|
|
566
|
+
'warn',
|
|
567
|
+
'Capture reservation stays outstanding after a failed capture',
|
|
568
|
+
{ operationId: operation.operationId, reservedAmount: formatAmountUnits(reservedUnits) },
|
|
569
|
+
)
|
|
570
|
+
},
|
|
501
571
|
invoke: ({ adapter, credentials, transaction, idempotencyKey }) => adapter.capture({
|
|
502
572
|
sessionId: readProviderSessionId(transaction),
|
|
503
|
-
amount,
|
|
573
|
+
amount: providerAmount,
|
|
504
574
|
credentials,
|
|
505
575
|
idempotencyKey,
|
|
506
576
|
}),
|
|
@@ -509,6 +579,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
|
|
|
509
579
|
...readGatewayMetadata(transaction.gatewayMetadata),
|
|
510
580
|
captureResult: result.providerData,
|
|
511
581
|
}
|
|
582
|
+
if (reservedUnits !== null) settleCapturedAmount(transaction, reservedUnits, result.capturedAmount)
|
|
512
583
|
},
|
|
513
584
|
afterCommit: (transaction, result) => writeTransactionLog(
|
|
514
585
|
transaction.providerKey,
|
|
@@ -604,6 +675,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
|
|
|
604
675
|
if (status.status !== transaction.unifiedStatus && isValidTransition(transaction.unifiedStatus as UnifiedPaymentStatus, status.status)) {
|
|
605
676
|
const previousStatus = transaction.unifiedStatus
|
|
606
677
|
transaction.unifiedStatus = status.status
|
|
678
|
+
alignCapturedAmountWithStatus(transaction, status.status)
|
|
607
679
|
transaction.gatewayStatus = status.status
|
|
608
680
|
transaction.gatewayMetadata = { ...readGatewayMetadata(transaction.gatewayMetadata), statusResult: status.providerData ?? null }
|
|
609
681
|
transaction.lastPolledAt = new Date()
|
|
@@ -650,6 +722,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
|
|
|
650
722
|
const previousStatus = transaction.unifiedStatus
|
|
651
723
|
if (shouldApplyStatus) {
|
|
652
724
|
transaction.unifiedStatus = update.unifiedStatus
|
|
725
|
+
alignCapturedAmountWithStatus(transaction, update.unifiedStatus)
|
|
653
726
|
}
|
|
654
727
|
if (update.providerStatus) {
|
|
655
728
|
transaction.gatewayStatus = update.providerStatus
|
|
@@ -170,6 +170,7 @@ export async function preparePaymentOperation(
|
|
|
170
170
|
attemptToken: claim.attemptToken,
|
|
171
171
|
attemptCount: 1,
|
|
172
172
|
result: null,
|
|
173
|
+
reservedAmount: null,
|
|
173
174
|
leaseExpiresAt: claim.leaseExpiresAt,
|
|
174
175
|
organizationId: input.scope.organizationId,
|
|
175
176
|
tenantId: input.scope.tenantId,
|
|
@@ -195,6 +195,23 @@
|
|
|
195
195
|
"enumItems": [],
|
|
196
196
|
"mappedType": "text"
|
|
197
197
|
},
|
|
198
|
+
"reserved_amount": {
|
|
199
|
+
"name": "reserved_amount",
|
|
200
|
+
"type": "numeric(18,4)",
|
|
201
|
+
"unsigned": false,
|
|
202
|
+
"autoincrement": false,
|
|
203
|
+
"primary": false,
|
|
204
|
+
"nullable": true,
|
|
205
|
+
"unique": false,
|
|
206
|
+
"length": null,
|
|
207
|
+
"precision": 18,
|
|
208
|
+
"scale": 4,
|
|
209
|
+
"default": null,
|
|
210
|
+
"comment": null,
|
|
211
|
+
"collation": null,
|
|
212
|
+
"enumItems": [],
|
|
213
|
+
"mappedType": "decimal"
|
|
214
|
+
},
|
|
198
215
|
"result": {
|
|
199
216
|
"name": "result",
|
|
200
217
|
"type": "jsonb",
|
|
@@ -560,6 +577,23 @@
|
|
|
560
577
|
"enumItems": [],
|
|
561
578
|
"mappedType": "decimal"
|
|
562
579
|
},
|
|
580
|
+
"captured_amount": {
|
|
581
|
+
"name": "captured_amount",
|
|
582
|
+
"type": "numeric(18,4)",
|
|
583
|
+
"unsigned": false,
|
|
584
|
+
"autoincrement": false,
|
|
585
|
+
"primary": false,
|
|
586
|
+
"nullable": false,
|
|
587
|
+
"unique": false,
|
|
588
|
+
"length": null,
|
|
589
|
+
"precision": 18,
|
|
590
|
+
"scale": 4,
|
|
591
|
+
"default": "'0'",
|
|
592
|
+
"comment": null,
|
|
593
|
+
"collation": null,
|
|
594
|
+
"enumItems": [],
|
|
595
|
+
"mappedType": "decimal"
|
|
596
|
+
},
|
|
563
597
|
"client_secret": {
|
|
564
598
|
"name": "client_secret",
|
|
565
599
|
"type": "text",
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
|
|
3
|
+
export class Migration20260725101500_payment_gateways extends Migration {
|
|
4
|
+
|
|
5
|
+
override up(): void | Promise<void> {
|
|
6
|
+
this.addSql(`alter table "gateway_transactions" add column "captured_amount" numeric(18,4) not null default '0';`);
|
|
7
|
+
this.addSql(`alter table "gateway_payment_operations" add column "reserved_amount" numeric(18,4) null;`);
|
|
8
|
+
// Backfill the captured-to-date ledger from the succeeded capture operations, which are the
|
|
9
|
+
// only capture path since the payment operation ledger landed. Transactions captured before
|
|
10
|
+
// that (no succeeded capture rows) fall back to their full amount when their status says the
|
|
11
|
+
// money was taken. A historical `partially_captured` transaction has no recoverable captured
|
|
12
|
+
// amount, so it starts at 0 and can still be over-captured once.
|
|
13
|
+
this.addSql(`update "gateway_transactions" as t set "captured_amount" = coalesce((select sum((o."result"->>'capturedAmount')::numeric) from "gateway_payment_operations" as o where o."transaction_id" = t."id" and o."operation_type" = 'capture' and o."status" = 'succeeded'), case when t."unified_status" in ('captured', 'refunded', 'partially_refunded') then t."amount" else 0 end);`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
override down(): void | Promise<void> {
|
|
17
|
+
this.addSql(`alter table "gateway_payment_operations" drop column "reserved_amount";`);
|
|
18
|
+
this.addSql(`alter table "gateway_transactions" drop column "captured_amount";`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
}
|
|
@@ -3,9 +3,12 @@ import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'
|
|
|
3
3
|
import { resolveTenantEncryptionService } from '@open-mercato/shared/lib/encryption/customFieldValues'
|
|
4
4
|
import { decryptIndexDocForSearch, encryptIndexDocForStorage } from '@open-mercato/shared/lib/encryption/indexDoc'
|
|
5
5
|
import { sql } from 'kysely'
|
|
6
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
6
7
|
import { replaceSearchTokensForRecord, deleteSearchTokensForRecord } from './search-tokens'
|
|
7
8
|
import { attachAggregateSearchField } from './document'
|
|
8
9
|
|
|
10
|
+
const logger = createLogger('query_index').child({ component: 'indexer' })
|
|
11
|
+
|
|
9
12
|
type BuildDocParams = {
|
|
10
13
|
entityType: string // '<module>:<entity>'
|
|
11
14
|
recordId: string
|
|
@@ -121,8 +124,12 @@ export async function buildIndexDoc(em: EntityManager, params: BuildDocParams):
|
|
|
121
124
|
}
|
|
122
125
|
} catch {}
|
|
123
126
|
|
|
127
|
+
// Kept outside the guard below: a failure while building the aggregate search field is a
|
|
128
|
+
// bug in the aggregation or its configuration, and must surface instead of being mistaken
|
|
129
|
+
// for an encryption failure and silently skipping encryption.
|
|
130
|
+
doc = attachAggregateSearchField(doc)
|
|
131
|
+
|
|
124
132
|
try {
|
|
125
|
-
doc = attachAggregateSearchField(doc)
|
|
126
133
|
const encryption = resolveTenantEncryptionService(em as any)
|
|
127
134
|
doc = await encryptIndexDocForStorage(
|
|
128
135
|
params.entityType,
|
|
@@ -130,7 +137,23 @@ export async function buildIndexDoc(em: EntityManager, params: BuildDocParams):
|
|
|
130
137
|
{ tenantId: params.tenantId ?? null, organizationId: params.organizationId ?? null },
|
|
131
138
|
encryption,
|
|
132
139
|
)
|
|
133
|
-
} catch {
|
|
140
|
+
} catch (error) {
|
|
141
|
+
// `encryptIndexDocForStorage` already returns the document untouched when encryption is
|
|
142
|
+
// absent or disabled, so a throw here is always a genuine encryption failure. Returning
|
|
143
|
+
// `doc` would hand the caller the plaintext document to write into `entity_indexes`,
|
|
144
|
+
// which must stay encrypted at rest; returning `null` would mean "record gone" and make
|
|
145
|
+
// `upsertIndexRow` delete a healthy index row. Fail loudly instead: the projection keeps
|
|
146
|
+
// its previous, correctly encrypted contents and the `query_index.upsert_one` subscriber
|
|
147
|
+
// persists the error via `recordIndexerError`.
|
|
148
|
+
logger.error('Index document encryption failed; refusing to return an unencrypted document', {
|
|
149
|
+
entityType: params.entityType,
|
|
150
|
+
recordId: params.recordId,
|
|
151
|
+
tenantId: params.tenantId ?? null,
|
|
152
|
+
organizationId: params.organizationId ?? null,
|
|
153
|
+
err: error,
|
|
154
|
+
})
|
|
155
|
+
throw error
|
|
156
|
+
}
|
|
134
157
|
|
|
135
158
|
return doc
|
|
136
159
|
}
|
|
@@ -11,6 +11,7 @@ import { useState, useCallback, useEffect } from 'react'
|
|
|
11
11
|
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
|
|
12
12
|
import { graphToDefinition, definitionToGraph, validateWorkflowGraph, generateStepId, generateTransitionId, appendWorkflowEdge, ValidationError } from '../../../lib/graph-utils'
|
|
13
13
|
import { performDeleteEdgeFlow, performDeleteNodeFlow } from '../../../lib/visual-editor-delete-flow'
|
|
14
|
+
import { humanizeDefinitionIssuePath } from '../../../lib/format-validation-error'
|
|
14
15
|
import { workflowDefinitionDataSchema } from '../../../data/validators'
|
|
15
16
|
import { Page } from '@open-mercato/ui/backend/Page'
|
|
16
17
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
@@ -357,11 +358,19 @@ export default function VisualEditorPage() {
|
|
|
357
358
|
triggers: triggers.length > 0 ? triggers : undefined,
|
|
358
359
|
}
|
|
359
360
|
|
|
360
|
-
// Run Zod schema validation before saving
|
|
361
|
+
// Run Zod schema validation before saving. Report every issue, not just the
|
|
362
|
+
// first: a save rejected for one missing activity field used to look like
|
|
363
|
+
// "nothing happened" once the operator fixed that field and hit a second
|
|
364
|
+
// one (#4232). Paths are humanized (steps.2.activities.0.config.endpoint →
|
|
365
|
+
// step 3 › activity 1 › endpoint) so the message points at the node to open.
|
|
361
366
|
const schemaResult = workflowDefinitionDataSchema.safeParse(definitionData)
|
|
362
367
|
if (!schemaResult.success) {
|
|
363
|
-
const
|
|
364
|
-
|
|
368
|
+
const issues = schemaResult.error.issues
|
|
369
|
+
const described = issues
|
|
370
|
+
.slice(0, 3)
|
|
371
|
+
.map((issue) => `${humanizeDefinitionIssuePath(issue.path)}: ${issue.message}`)
|
|
372
|
+
const suffix = issues.length > described.length ? ` (+${issues.length - described.length} more)` : ''
|
|
373
|
+
flash(`Cannot save — ${described.join('; ')}${suffix}`, 'error')
|
|
365
374
|
return
|
|
366
375
|
}
|
|
367
376
|
|