@open-mercato/core 0.6.7-develop.6695.1.d1b48a09b3 → 0.6.7-develop.6706.1.b3a4c759bb

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 (45) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/frontend/login.js +4 -1
  3. package/dist/modules/auth/frontend/login.js.map +2 -2
  4. package/dist/modules/customers/api/companies/[id]/route.js +3 -4
  5. package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
  6. package/dist/modules/customers/api/people/[id]/companies/enriched/route.js +3 -2
  7. package/dist/modules/customers/api/people/[id]/companies/enriched/route.js.map +2 -2
  8. package/dist/modules/customers/components/detail/ActiveDealCard.js +2 -1
  9. package/dist/modules/customers/components/detail/ActiveDealCard.js.map +2 -2
  10. package/dist/modules/customers/components/detail/CompanyKpiBar.js +6 -5
  11. package/dist/modules/customers/components/detail/CompanyKpiBar.js.map +2 -2
  12. package/dist/modules/customers/components/detail/dashboard/helpers.js +4 -3
  13. package/dist/modules/customers/components/detail/dashboard/helpers.js.map +2 -2
  14. package/dist/modules/customers/lib/dealStatus.js +38 -0
  15. package/dist/modules/customers/lib/dealStatus.js.map +7 -0
  16. package/dist/modules/payment_gateways/api/sessions/route.js +5 -0
  17. package/dist/modules/payment_gateways/api/sessions/route.js.map +2 -2
  18. package/dist/modules/payment_gateways/di.js +17 -1
  19. package/dist/modules/payment_gateways/di.js.map +2 -2
  20. package/dist/modules/payment_gateways/lib/gateway-service.js +8 -0
  21. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  22. package/dist/modules/payment_gateways/lib/order-amount-reconciliation.js +58 -0
  23. package/dist/modules/payment_gateways/lib/order-amount-reconciliation.js.map +7 -0
  24. package/dist/modules/sales/di.js +7 -0
  25. package/dist/modules/sales/di.js.map +2 -2
  26. package/dist/modules/sales/services/paymentOrderTotalResolver.js +52 -0
  27. package/dist/modules/sales/services/paymentOrderTotalResolver.js.map +7 -0
  28. package/package.json +7 -7
  29. package/src/modules/auth/frontend/login.tsx +4 -1
  30. package/src/modules/customers/api/companies/[id]/route.ts +3 -4
  31. package/src/modules/customers/api/people/[id]/companies/enriched/route.ts +3 -2
  32. package/src/modules/customers/components/detail/ActiveDealCard.tsx +2 -1
  33. package/src/modules/customers/components/detail/CompanyKpiBar.tsx +6 -5
  34. package/src/modules/customers/components/detail/dashboard/helpers.ts +4 -3
  35. package/src/modules/customers/lib/dealStatus.ts +48 -0
  36. package/src/modules/payment_gateways/api/sessions/route.ts +5 -0
  37. package/src/modules/payment_gateways/di.ts +28 -2
  38. package/src/modules/payment_gateways/i18n/de.json +3 -0
  39. package/src/modules/payment_gateways/i18n/en.json +3 -0
  40. package/src/modules/payment_gateways/i18n/es.json +3 -0
  41. package/src/modules/payment_gateways/i18n/pl.json +3 -0
  42. package/src/modules/payment_gateways/lib/gateway-service.ts +14 -0
  43. package/src/modules/payment_gateways/lib/order-amount-reconciliation.ts +93 -0
  44. package/src/modules/sales/di.ts +9 -0
  45. package/src/modules/sales/services/paymentOrderTotalResolver.ts +67 -0
@@ -11,6 +11,7 @@ import {
11
11
  writeVersionedIdSet,
12
12
  clearVersionedPreference,
13
13
  } from '@open-mercato/shared/lib/browser/versionedPreference'
14
+ import { isOpenDealStatus, isWonDealStatus } from '../../lib/dealStatus'
14
15
  import type { CompanyOverview, DealSummary, InteractionSummary } from '../formConfig'
15
16
  import { formatCurrency } from './utils'
16
17
 
@@ -19,7 +20,7 @@ const STORAGE_VERSION = 1
19
20
 
20
21
  function sumActiveDeals(deals: DealSummary[]): number {
21
22
  return deals
22
- .filter((d) => d.status !== 'won' && d.status !== 'lost' && d.status !== 'closed')
23
+ .filter((d) => isOpenDealStatus(d.status))
23
24
  .reduce((sum, d) => {
24
25
  const amount = typeof d.valueAmount === 'number' ? d.valueAmount : parseFloat(String(d.valueAmount ?? '0'))
25
26
  return sum + (Number.isFinite(amount) ? amount : 0)
@@ -27,7 +28,7 @@ function sumActiveDeals(deals: DealSummary[]): number {
27
28
  }
28
29
 
29
30
  function getActiveDeals(deals: DealSummary[]): DealSummary[] {
30
- return deals.filter((d) => d.status !== 'won' && d.status !== 'lost' && d.status !== 'closed')
31
+ return deals.filter((d) => isOpenDealStatus(d.status))
31
32
  }
32
33
 
33
34
  function computeActivityTrend(interactions: InteractionSummary[]): KpiTrend | undefined {
@@ -51,7 +52,7 @@ function computeActivityTrend(interactions: InteractionSummary[]): KpiTrend | un
51
52
  }
52
53
 
53
54
  function computeDealTrend(deals: DealSummary[]): KpiTrend | undefined {
54
- const active = deals.filter((d) => d.status !== 'won' && d.status !== 'lost' && d.status !== 'closed')
55
+ const active = deals.filter((d) => isOpenDealStatus(d.status))
55
56
  if (active.length === 0) return undefined
56
57
  const now = Date.now()
57
58
  const monthMs = 30 * 86_400_000
@@ -81,7 +82,7 @@ export function CompanyKpiBar({ data }: CompanyKpiBarProps) {
81
82
 
82
83
  const ltvValue = React.useMemo(() => {
83
84
  if (data.kpis?.ltvValue !== undefined) return data.kpis.ltvValue
84
- const wonDeals = data.deals.filter((d) => d.status === 'won')
85
+ const wonDeals = data.deals.filter((d) => isWonDealStatus(d.status))
85
86
  if (wonDeals.length === 0) return null
86
87
  return wonDeals.reduce((sum, d) => {
87
88
  const amt = typeof d.valueAmount === 'number' ? d.valueAmount : parseFloat(String(d.valueAmount ?? '0'))
@@ -154,7 +155,7 @@ export function CompanyKpiBar({ data }: CompanyKpiBarProps) {
154
155
  : `${v} ${v === 1 ? t('customers.companies.dashboard.kpi.year', 'year') : t('customers.companies.dashboard.kpi.years', 'years')}`
155
156
  : undefined,
156
157
  comparisonLabel: clientTenureYears !== null
157
- ? `${data.kpis?.completedDealsCount ?? data.deals.filter((d) => d.status === 'won').length} ${t('customers.companies.dashboard.kpi.completedDeals', 'completed deals')}`
158
+ ? `${data.kpis?.completedDealsCount ?? data.deals.filter((d) => isWonDealStatus(d.status)).length} ${t('customers.companies.dashboard.kpi.completedDeals', 'completed deals')}`
158
159
  : t('customers.companies.dashboard.kpi.noInteractions', 'No interactions yet'),
159
160
  },
160
161
  ], [t, activeDealsValue, dealTrend, dealCurrency, activeDeals.length, activityTrend, ltvValue, clientTenureYears, data.deals, data.interactions.length, data.kpis])
@@ -1,9 +1,10 @@
1
1
  import type { KpiTrend } from '@open-mercato/ui/backend/charts/KpiCard'
2
+ import { isOpenDealStatus } from '../../../lib/dealStatus'
2
3
  import type { DealSummary, InteractionSummary, TodoLinkSummary } from '../../formConfig'
3
4
 
4
5
  export function sumActiveDeals(deals: DealSummary[]): number {
5
6
  return deals
6
- .filter((d) => d.status !== 'won' && d.status !== 'lost' && d.status !== 'closed')
7
+ .filter((d) => isOpenDealStatus(d.status))
7
8
  .reduce((sum, d) => {
8
9
  const amount = typeof d.valueAmount === 'number' ? d.valueAmount : parseFloat(String(d.valueAmount ?? '0'))
9
10
  return sum + (Number.isFinite(amount) ? amount : 0)
@@ -11,7 +12,7 @@ export function sumActiveDeals(deals: DealSummary[]): number {
11
12
  }
12
13
 
13
14
  export function getActiveDeals(deals: DealSummary[]): DealSummary[] {
14
- return deals.filter((d) => d.status !== 'won' && d.status !== 'lost' && d.status !== 'closed')
15
+ return deals.filter((d) => isOpenDealStatus(d.status))
15
16
  }
16
17
 
17
18
  export function getOpenTasks(todos: TodoLinkSummary[]): TodoLinkSummary[] {
@@ -68,7 +69,7 @@ export function computeActivityTrend(interactions: InteractionSummary[]): KpiTre
68
69
  }
69
70
 
70
71
  export function computeDealTrend(deals: DealSummary[]): KpiTrend | undefined {
71
- const active = deals.filter((d) => d.status !== 'won' && d.status !== 'lost' && d.status !== 'closed')
72
+ const active = deals.filter((d) => isOpenDealStatus(d.status))
72
73
  if (active.length === 0) return undefined
73
74
  const now = Date.now()
74
75
  const monthMs = 30 * 86_400_000
@@ -0,0 +1,48 @@
1
+ // Single source of truth for deal open/closed (terminal) semantics.
2
+ //
3
+ // `customer_deals.status` is a lenient text column and the supported writers do not agree on
4
+ // one spelling: the closure hooks and the kanban board persist `win` / `loose`, the AI tool
5
+ // `customers.update_deal_stage` persists `won` / `lost`, and the seeded vocabulary also carries
6
+ // `closed`. The terminal set below therefore covers every spelling a status writer persists, so
7
+ // status-based readers can share one classification. `closureOutcome` is a separate signal and
8
+ // callers that combine both columns must handle it explicitly.
9
+ //
10
+ // A status not known to code counts as OPEN. Deal stages are configurable per tenant, so the
11
+ // lenient column can hold values this module has never seen — treating them as open keeps them
12
+ // visible in active-deal counts instead of silently reclassifying them as closed.
13
+
14
+ export const DEAL_STATUS_WIN = 'win' as const
15
+ export const DEAL_STATUS_LOSE = 'loose' as const
16
+ export const DEAL_STATUS_CLOSED = 'closed' as const
17
+
18
+ // `won` / `lost` are the spellings the AI stage tool writes; `win` / `loose` are what the UI
19
+ // closure flows persist. Both are accepted so neither writer produces an unreadable status.
20
+ export const WON_DEAL_STATUS_LIST: readonly string[] = [DEAL_STATUS_WIN, 'won']
21
+
22
+ export const LOST_DEAL_STATUS_LIST: readonly string[] = [DEAL_STATUS_LOSE, 'lost']
23
+
24
+ export const CLOSED_DEAL_STATUS_LIST: readonly string[] = [
25
+ ...WON_DEAL_STATUS_LIST,
26
+ ...LOST_DEAL_STATUS_LIST,
27
+ DEAL_STATUS_CLOSED,
28
+ ]
29
+
30
+ const WON_DEAL_STATUSES = new Set<string>(WON_DEAL_STATUS_LIST)
31
+ const LOST_DEAL_STATUSES = new Set<string>(LOST_DEAL_STATUS_LIST)
32
+ const CLOSED_DEAL_STATUSES = new Set<string>(CLOSED_DEAL_STATUS_LIST)
33
+
34
+ export function isWonDealStatus(value: string | null | undefined): boolean {
35
+ return value != null && WON_DEAL_STATUSES.has(value)
36
+ }
37
+
38
+ export function isLostDealStatus(value: string | null | undefined): boolean {
39
+ return value != null && LOST_DEAL_STATUSES.has(value)
40
+ }
41
+
42
+ export function isClosedDealStatus(value: string | null | undefined): boolean {
43
+ return value != null && CLOSED_DEAL_STATUSES.has(value)
44
+ }
45
+
46
+ export function isOpenDealStatus(value: string | null | undefined): boolean {
47
+ return !isClosedDealStatus(value)
48
+ }
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
3
3
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
4
  import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
5
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
5
6
  import { createSessionSchema } from '../../data/validators'
6
7
  import type { PaymentGatewayService } from '../../lib/gateway-service'
7
8
  import { paymentGatewaysTag } from '../openapi'
@@ -98,6 +99,9 @@ export async function POST(req: Request) {
98
99
  paymentId: transaction.paymentId,
99
100
  }, { status: 201 })
100
101
  } catch (err: unknown) {
102
+ if (isCrudHttpError(err)) {
103
+ return NextResponse.json(err.body, { status: err.status })
104
+ }
101
105
  const message = err instanceof Error ? err.message : 'Failed to create payment session'
102
106
  const status = message.includes('No gateway adapter') ? 422 : 502
103
107
  return NextResponse.json({ error: message }, { status })
@@ -113,6 +117,7 @@ export const openApi = {
113
117
  tags: [paymentGatewaysTag],
114
118
  responses: [
115
119
  { status: 201, description: 'Payment session created' },
120
+ { status: 409, description: 'Amount or currency does not match the referenced order' },
116
121
  { status: 422, description: 'Invalid payload or unknown provider' },
117
122
  { status: 502, description: 'Gateway provider error' },
118
123
  ],
@@ -4,9 +4,11 @@ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
4
4
  import type { CredentialsService } from '../integrations/lib/credentials-service'
5
5
  import type { IntegrationLogService } from '../integrations/lib/log-service'
6
6
  import type { IntegrationStateService } from '../integrations/lib/state-service'
7
+ import type { PaymentOrderTotalResolver } from '@open-mercato/shared/modules/payment_gateways/types'
7
8
  import { GatewayTransaction, WebhookProcessedEvent } from './data/entities'
8
9
  import { createPaymentGatewayDescriptorService } from './lib/descriptor-service'
9
10
  import { createPaymentGatewayService } from './lib/gateway-service'
11
+ import { isPaymentOrderTotalResolver } from './lib/order-amount-reconciliation'
10
12
 
11
13
  type Cradle = {
12
14
  em: EntityManager
@@ -15,10 +17,34 @@ type Cradle = {
15
17
  integrationStateService: IntegrationStateService
16
18
  }
17
19
 
20
+ const ORDER_TOTAL_RESOLVER_NAME = 'paymentOrderTotalResolver'
21
+
22
+ /**
23
+ * The order-total resolver is owned by whichever module owns orders (`sales`
24
+ * registers the default one). Its absence is the only supported reason to skip
25
+ * amount reconciliation, so this only tolerates a missing registration: a
26
+ * registered resolver that fails to build or does not satisfy the contract
27
+ * throws instead of silently disabling the check.
28
+ */
29
+ function resolveOrderTotalResolver(container: AppContainer, cradle: Cradle): PaymentOrderTotalResolver | null {
30
+ if (!container.hasRegistration(ORDER_TOTAL_RESOLVER_NAME)) return null
31
+ const candidate = (cradle as Cradle & { paymentOrderTotalResolver?: unknown })[ORDER_TOTAL_RESOLVER_NAME]
32
+ if (!isPaymentOrderTotalResolver(candidate)) {
33
+ throw new Error(`[internal] ${ORDER_TOTAL_RESOLVER_NAME} does not implement resolveOrderTotal`)
34
+ }
35
+ return candidate
36
+ }
37
+
18
38
  export function register(container: AppContainer) {
19
39
  container.register({
20
- paymentGatewayService: asFunction(({ em, integrationCredentialsService, integrationLogService, integrationStateService }: Cradle) =>
21
- createPaymentGatewayService({ em, integrationCredentialsService, integrationLogService, integrationStateService }),
40
+ paymentGatewayService: asFunction((cradle: Cradle) =>
41
+ createPaymentGatewayService({
42
+ em: cradle.em,
43
+ integrationCredentialsService: cradle.integrationCredentialsService,
44
+ integrationLogService: cradle.integrationLogService,
45
+ integrationStateService: cradle.integrationStateService,
46
+ paymentOrderTotalResolver: resolveOrderTotalResolver(container, cradle),
47
+ }),
22
48
  ).scoped().proxy(),
23
49
  paymentGatewayDescriptorService: asFunction(({ integrationCredentialsService, integrationStateService }: Cradle) =>
24
50
  createPaymentGatewayDescriptorService({ integrationCredentialsService, integrationStateService }),
@@ -2,6 +2,9 @@
2
2
  "payment_gateways.captureMethod.automatic": "Automatisch",
3
3
  "payment_gateways.captureMethod.manual": "Manuell",
4
4
  "payment_gateways.column.gatewayStatus": "Gateway-Status",
5
+ "payment_gateways.errors.sessionAmountMismatch": "Der Betrag der Zahlungssitzung {amount} stimmt nicht mit dem fälligen Betrag der Bestellung {orderId} überein",
6
+ "payment_gateways.errors.sessionCurrencyMismatch": "Die Währung der Zahlungssitzung {currencyCode} stimmt nicht mit der Währung der Bestellung {orderId} überein",
7
+ "payment_gateways.errors.sessionOrderNotFound": "Bestellung {orderId} wurde im aktuellen Gültigkeitsbereich nicht gefunden",
5
8
  "payment_gateways.feature.capture": "Autorisierte Zahlungen erfassen",
6
9
  "payment_gateways.feature.manage": "Zahlungssitzungen verwalten",
7
10
  "payment_gateways.feature.refund": "Erfasste Zahlungen erstatten",
@@ -2,6 +2,9 @@
2
2
  "payment_gateways.captureMethod.automatic": "Automatic",
3
3
  "payment_gateways.captureMethod.manual": "Manual",
4
4
  "payment_gateways.column.gatewayStatus": "Gateway status",
5
+ "payment_gateways.errors.sessionAmountMismatch": "Payment session amount {amount} does not match the amount due for order {orderId}",
6
+ "payment_gateways.errors.sessionCurrencyMismatch": "Payment session currency {currencyCode} does not match the currency of order {orderId}",
7
+ "payment_gateways.errors.sessionOrderNotFound": "Order {orderId} was not found in the current scope",
5
8
  "payment_gateways.feature.capture": "Capture authorized payments",
6
9
  "payment_gateways.feature.manage": "Manage payment sessions",
7
10
  "payment_gateways.feature.refund": "Refund captured payments",
@@ -2,6 +2,9 @@
2
2
  "payment_gateways.captureMethod.automatic": "Automatica",
3
3
  "payment_gateways.captureMethod.manual": "Manual",
4
4
  "payment_gateways.column.gatewayStatus": "Estado de la pasarela",
5
+ "payment_gateways.errors.sessionAmountMismatch": "El importe de la sesión de pago {amount} no coincide con el importe pendiente del pedido {orderId}",
6
+ "payment_gateways.errors.sessionCurrencyMismatch": "La moneda de la sesión de pago {currencyCode} no coincide con la moneda del pedido {orderId}",
7
+ "payment_gateways.errors.sessionOrderNotFound": "No se encontró el pedido {orderId} en el ámbito actual",
5
8
  "payment_gateways.feature.capture": "Capturar pagos autorizados",
6
9
  "payment_gateways.feature.manage": "Gestionar sesiones de pago",
7
10
  "payment_gateways.feature.refund": "Reembolsar pagos capturados",
@@ -2,6 +2,9 @@
2
2
  "payment_gateways.captureMethod.automatic": "Automatyczny",
3
3
  "payment_gateways.captureMethod.manual": "Manualny",
4
4
  "payment_gateways.column.gatewayStatus": "Status bramki",
5
+ "payment_gateways.errors.sessionAmountMismatch": "Kwota sesji płatności {amount} nie zgadza się z kwotą do zapłaty dla zamówienia {orderId}",
6
+ "payment_gateways.errors.sessionCurrencyMismatch": "Waluta sesji płatności {currencyCode} nie zgadza się z walutą zamówienia {orderId}",
7
+ "payment_gateways.errors.sessionOrderNotFound": "Nie znaleziono zamówienia {orderId} w bieżącym zakresie",
5
8
  "payment_gateways.feature.capture": "Przechwytywanie autoryzowanych płatności",
6
9
  "payment_gateways.feature.manage": "Zarządzanie sesjami płatności",
7
10
  "payment_gateways.feature.refund": "Zwroty przechwyconych płatności",
@@ -11,6 +11,7 @@ import {
11
11
  type GatewayAdapter,
12
12
  type GatewayPaymentStatus,
13
13
  type PaymentGatewayPresentationRequest,
14
+ type PaymentOrderTotalResolver,
14
15
  type UnifiedPaymentStatus,
15
16
  } from '@open-mercato/shared/modules/payment_gateways/types'
16
17
  import type { CredentialsService } from '../../integrations/lib/credentials-service'
@@ -21,6 +22,7 @@ import { GatewayPaymentOperation, GatewaySessionInitialization, GatewayTransacti
21
22
  import { canApplyManualAction, isValidTransition, type ManualGatewayAction } from './status-machine'
22
23
  import { emitPaymentGatewayEvent } from '../events'
23
24
  import { readGatewayMetadata, readWebhookLog } from './transaction-fields'
25
+ import { reconcileSessionAmountWithOrder } from './order-amount-reconciliation'
24
26
  import {
25
27
  alignCapturedAmountWithStatus,
26
28
  assertCaptureWithinRemaining,
@@ -76,6 +78,11 @@ export interface PaymentGatewayServiceDeps {
76
78
  integrationCredentialsService: CredentialsService
77
79
  integrationStateService?: IntegrationStateService
78
80
  integrationLogService?: IntegrationLogService
81
+ /**
82
+ * Optional seam supplied by the module that owns orders (`sales` by default).
83
+ * When absent, session amounts cannot be reconciled and are accepted as-is.
84
+ */
85
+ paymentOrderTotalResolver?: PaymentOrderTotalResolver | null
79
86
  sessionClaimOptions?: {
80
87
  staleAfterMs?: number
81
88
  heartbeatIntervalMs?: number
@@ -405,6 +412,13 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
405
412
  return {
406
413
  async createPaymentSession(input: CreatePaymentSessionInput): Promise<{ transaction: GatewayTransaction; session: CreateSessionResult }> {
407
414
  const scope = { organizationId: input.organizationId, tenantId: input.tenantId }
415
+ await reconcileSessionAmountWithOrder({
416
+ orderId: input.orderId,
417
+ amount: input.amount,
418
+ currencyCode: input.currencyCode,
419
+ scope,
420
+ resolver: deps.paymentOrderTotalResolver,
421
+ })
408
422
  const { adapter, credentials } = await resolveAdapterAndCredentials(input.providerKey, scope)
409
423
 
410
424
  const sessionInput: CreateSessionInput = {
@@ -0,0 +1,93 @@
1
+ import { conflict } from '@open-mercato/shared/lib/crud/errors'
2
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
3
+ import { createFallbackTranslator, type TranslateWithFallbackFn } from '@open-mercato/shared/lib/i18n/translate'
4
+ import type {
5
+ PaymentGatewayScope,
6
+ PaymentOrderTotal,
7
+ PaymentOrderTotalResolver,
8
+ } from '@open-mercato/shared/modules/payment_gateways/types'
9
+
10
+ /** Amounts are stored as numeric(18,4); anything below that is rounding noise, not a mismatch. */
11
+ const AMOUNT_TOLERANCE = 0.0001
12
+
13
+ function normalizeCurrencyCode(currencyCode: string): string {
14
+ return currencyCode.trim().toUpperCase()
15
+ }
16
+
17
+ /**
18
+ * Conflict copy is served from the module catalog on a request path. Contexts
19
+ * without a registered module dictionary (CLI commands, workers, unit tests)
20
+ * fall back to the English template shipped with each call, so a missing
21
+ * dictionary degrades the wording of a rejection but never its outcome.
22
+ */
23
+ async function resolveConflictTranslator(): Promise<TranslateWithFallbackFn> {
24
+ try {
25
+ const { translate } = await resolveTranslations()
26
+ return translate
27
+ } catch {
28
+ return createFallbackTranslator({})
29
+ }
30
+ }
31
+
32
+ export function isPaymentOrderTotalResolver(candidate: unknown): candidate is PaymentOrderTotalResolver {
33
+ return !!candidate
34
+ && typeof candidate === 'object'
35
+ && typeof (candidate as PaymentOrderTotalResolver).resolveOrderTotal === 'function'
36
+ }
37
+
38
+ export function assertSessionAmountMatchesOrderTotal(
39
+ requested: { orderId: string; amount: number; currencyCode: string },
40
+ orderTotal: PaymentOrderTotal,
41
+ translate: TranslateWithFallbackFn,
42
+ ): void {
43
+ if (normalizeCurrencyCode(requested.currencyCode) !== normalizeCurrencyCode(orderTotal.currencyCode)) {
44
+ throw conflict(translate(
45
+ 'payment_gateways.errors.sessionCurrencyMismatch',
46
+ 'Payment session currency {currencyCode} does not match the currency of order {orderId}',
47
+ { currencyCode: normalizeCurrencyCode(requested.currencyCode), orderId: requested.orderId },
48
+ ))
49
+ }
50
+ if (Math.abs(requested.amount - orderTotal.amountDue) > AMOUNT_TOLERANCE) {
51
+ throw conflict(translate(
52
+ 'payment_gateways.errors.sessionAmountMismatch',
53
+ 'Payment session amount {amount} does not match the amount due for order {orderId}',
54
+ { amount: requested.amount, orderId: requested.orderId },
55
+ ))
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Reconciles a caller-supplied session amount against the authoritative amount
61
+ * due for the referenced order. Reconciliation is skipped when the request
62
+ * references no order, or when no module registered a resolver (for example an
63
+ * installation without the `sales` module) — there is nothing authoritative to
64
+ * compare against in either case. A referenced order that does not resolve
65
+ * inside the caller's tenant/organization scope is rejected exactly like an
66
+ * unknown one, so the response never reveals whether it exists elsewhere.
67
+ */
68
+ export async function reconcileSessionAmountWithOrder(input: {
69
+ orderId?: string
70
+ amount: number
71
+ currencyCode: string
72
+ scope: PaymentGatewayScope
73
+ resolver?: PaymentOrderTotalResolver | null
74
+ }): Promise<void> {
75
+ const { orderId, resolver } = input
76
+ if (!orderId || !resolver) return
77
+
78
+ const orderTotal = await resolver.resolveOrderTotal(orderId, input.scope)
79
+ const translate = await resolveConflictTranslator()
80
+ if (!orderTotal) {
81
+ throw conflict(translate(
82
+ 'payment_gateways.errors.sessionOrderNotFound',
83
+ 'Order {orderId} was not found in the current scope',
84
+ { orderId },
85
+ ))
86
+ }
87
+
88
+ assertSessionAmountMatchesOrderTotal(
89
+ { orderId, amount: input.amount, currencyCode: input.currencyCode },
90
+ orderTotal,
91
+ translate,
92
+ )
93
+ }
@@ -8,6 +8,7 @@ import { DefaultSalesCalculationService } from './services/salesCalculationServi
8
8
  import { DefaultTaxCalculationService } from './services/taxCalculationService'
9
9
  import { SalesDocumentNumberGenerator } from './services/salesDocumentNumberGenerator'
10
10
  import { DefaultSalesOrderService } from './services/salesOrderService'
11
+ import { createSalesPaymentOrderTotalResolver } from './services/paymentOrderTotalResolver'
11
12
  import {
12
13
  SalesOrder,
13
14
  SalesOrderLine,
@@ -150,6 +151,14 @@ export function register(container: AppContainer) {
150
151
  })
151
152
  .singleton()
152
153
  .proxy(),
154
+ // Authoritative amount-due lookup consumed by `payment_gateways` when it
155
+ // reconciles a caller-supplied payment-session amount (#4488). Registering
156
+ // it here keeps the gateway module free of any dependency on sales.
157
+ paymentOrderTotalResolver: asFunction(({ em }: AppCradle) => {
158
+ return createSalesPaymentOrderTotalResolver({ em })
159
+ })
160
+ .scoped()
161
+ .proxy(),
153
162
  SalesOrder: asValue(SalesOrder),
154
163
  SalesOrderLine: asValue(SalesOrderLine),
155
164
  SalesOrderAdjustment: asValue(SalesOrderAdjustment),
@@ -0,0 +1,67 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
+ import type {
4
+ PaymentGatewayScope,
5
+ PaymentOrderTotal,
6
+ PaymentOrderTotalResolver,
7
+ } from '@open-mercato/shared/modules/payment_gateways/types'
8
+ import { SalesOrder } from '../data/entities'
9
+
10
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
11
+
12
+ function toAmount(value: string | number | null | undefined): number {
13
+ const parsed = typeof value === 'number' ? value : Number(value ?? 0)
14
+ return Number.isFinite(parsed) ? parsed : 0
15
+ }
16
+
17
+ /**
18
+ * The amount a gateway session may legitimately charge for an order: what is
19
+ * still due. `outstanding_amount` is recomputed from payments and refunds, so
20
+ * it is the authoritative value once either exists. Orders that were never
21
+ * paid fall back to the grand total, which also covers rows written before
22
+ * outstanding totals were tracked.
23
+ */
24
+ export function resolveOrderAmountDue(order: Pick<
25
+ SalesOrder,
26
+ 'outstandingAmount' | 'paidTotalAmount' | 'refundedTotalAmount' | 'grandTotalGrossAmount'
27
+ >): number {
28
+ const outstanding = toAmount(order.outstandingAmount)
29
+ const paid = toAmount(order.paidTotalAmount)
30
+ const refunded = toAmount(order.refundedTotalAmount)
31
+ if (outstanding > 0 || paid > 0 || refunded > 0) return outstanding
32
+ return toAmount(order.grandTotalGrossAmount)
33
+ }
34
+
35
+ export function createSalesPaymentOrderTotalResolver(deps: { em: EntityManager }): PaymentOrderTotalResolver {
36
+ return {
37
+ async resolveOrderTotal(orderId: string, scope: PaymentGatewayScope): Promise<PaymentOrderTotal | null> {
38
+ if (!UUID_PATTERN.test(orderId) || !scope.tenantId || !scope.organizationId) return null
39
+ const order = await findOneWithDecryption(
40
+ deps.em,
41
+ SalesOrder,
42
+ {
43
+ id: orderId,
44
+ tenantId: scope.tenantId,
45
+ organizationId: scope.organizationId,
46
+ deletedAt: null,
47
+ },
48
+ {
49
+ fields: [
50
+ 'currencyCode',
51
+ 'outstandingAmount',
52
+ 'paidTotalAmount',
53
+ 'refundedTotalAmount',
54
+ 'grandTotalGrossAmount',
55
+ ] as const,
56
+ },
57
+ scope,
58
+ )
59
+ if (!order) return null
60
+ return {
61
+ orderId,
62
+ currencyCode: order.currencyCode,
63
+ amountDue: resolveOrderAmountDue(order),
64
+ }
65
+ },
66
+ }
67
+ }