@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.
Files changed (55) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/gateway_payment_operation/index.js +2 -0
  3. package/dist/generated/entities/gateway_payment_operation/index.js.map +2 -2
  4. package/dist/generated/entities/gateway_transaction/index.js +2 -0
  5. package/dist/generated/entities/gateway_transaction/index.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +2 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/payment_gateways/api/capture/route.js +1 -1
  9. package/dist/modules/payment_gateways/api/capture/route.js.map +2 -2
  10. package/dist/modules/payment_gateways/data/entities.js +7 -0
  11. package/dist/modules/payment_gateways/data/entities.js.map +2 -2
  12. package/dist/modules/payment_gateways/lib/capture-ledger.js +183 -0
  13. package/dist/modules/payment_gateways/lib/capture-ledger.js.map +7 -0
  14. package/dist/modules/payment_gateways/lib/gateway-service.js +70 -8
  15. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  16. package/dist/modules/payment_gateways/lib/payment-operation-idempotency.js +1 -0
  17. package/dist/modules/payment_gateways/lib/payment-operation-idempotency.js.map +2 -2
  18. package/dist/modules/payment_gateways/migrations/Migration20260725101500_payment_gateways.js +16 -0
  19. package/dist/modules/payment_gateways/migrations/Migration20260725101500_payment_gateways.js.map +7 -0
  20. package/dist/modules/query_index/lib/indexer.js +12 -2
  21. package/dist/modules/query_index/lib/indexer.js.map +2 -2
  22. package/dist/modules/workflows/backend/definitions/visual-editor/page.js +5 -2
  23. package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
  24. package/dist/modules/workflows/components/ActivitiesEditor.js +67 -11
  25. package/dist/modules/workflows/components/ActivitiesEditor.js.map +2 -2
  26. package/dist/modules/workflows/components/TransitionsEditor.js +205 -163
  27. package/dist/modules/workflows/components/TransitionsEditor.js.map +2 -2
  28. package/dist/modules/workflows/data/validators.js +34 -1
  29. package/dist/modules/workflows/data/validators.js.map +3 -3
  30. package/dist/modules/workflows/lib/activity-executor.js +18 -2
  31. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  32. package/dist/modules/workflows/lib/format-validation-error.js +46 -1
  33. package/dist/modules/workflows/lib/format-validation-error.js.map +2 -2
  34. package/generated/entities/gateway_payment_operation/index.ts +1 -0
  35. package/generated/entities/gateway_transaction/index.ts +1 -0
  36. package/generated/entity-fields-registry.ts +2 -0
  37. package/package.json +7 -7
  38. package/src/modules/payment_gateways/api/capture/route.ts +1 -1
  39. package/src/modules/payment_gateways/data/entities.ts +8 -2
  40. package/src/modules/payment_gateways/lib/capture-ledger.ts +260 -0
  41. package/src/modules/payment_gateways/lib/gateway-service.ts +83 -10
  42. package/src/modules/payment_gateways/lib/payment-operation-idempotency.ts +1 -0
  43. package/src/modules/payment_gateways/migrations/.snapshot-open-mercato.json +34 -0
  44. package/src/modules/payment_gateways/migrations/Migration20260725101500_payment_gateways.ts +21 -0
  45. package/src/modules/query_index/lib/indexer.ts +25 -2
  46. package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +12 -3
  47. package/src/modules/workflows/components/ActivitiesEditor.tsx +96 -12
  48. package/src/modules/workflows/components/TransitionsEditor.tsx +75 -17
  49. package/src/modules/workflows/data/validators.ts +52 -1
  50. package/src/modules/workflows/i18n/de.json +2 -0
  51. package/src/modules/workflows/i18n/en.json +2 -0
  52. package/src/modules/workflows/i18n/es.json +2 -0
  53. package/src/modules/workflows/i18n/pl.json +2 -0
  54. package/src/modules/workflows/lib/activity-executor.ts +38 -3
  55. 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
- await Promise.allSettled([failPaymentOperation(em, prepared.claim)])
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) => assertCaptureAmountAllowed(amount, 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 firstIssue = schemaResult.error.issues[0]
364
- flash(`Schema error: ${firstIssue.path.join('.')} - ${firstIssue.message}`, 'error')
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