@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/auth/frontend/login.js +4 -1
- package/dist/modules/auth/frontend/login.js.map +2 -2
- package/dist/modules/customers/api/companies/[id]/route.js +3 -4
- package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/companies/enriched/route.js +3 -2
- package/dist/modules/customers/api/people/[id]/companies/enriched/route.js.map +2 -2
- package/dist/modules/customers/components/detail/ActiveDealCard.js +2 -1
- package/dist/modules/customers/components/detail/ActiveDealCard.js.map +2 -2
- package/dist/modules/customers/components/detail/CompanyKpiBar.js +6 -5
- package/dist/modules/customers/components/detail/CompanyKpiBar.js.map +2 -2
- package/dist/modules/customers/components/detail/dashboard/helpers.js +4 -3
- package/dist/modules/customers/components/detail/dashboard/helpers.js.map +2 -2
- package/dist/modules/customers/lib/dealStatus.js +38 -0
- package/dist/modules/customers/lib/dealStatus.js.map +7 -0
- package/dist/modules/payment_gateways/api/sessions/route.js +5 -0
- package/dist/modules/payment_gateways/api/sessions/route.js.map +2 -2
- package/dist/modules/payment_gateways/di.js +17 -1
- package/dist/modules/payment_gateways/di.js.map +2 -2
- package/dist/modules/payment_gateways/lib/gateway-service.js +8 -0
- package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
- package/dist/modules/payment_gateways/lib/order-amount-reconciliation.js +58 -0
- package/dist/modules/payment_gateways/lib/order-amount-reconciliation.js.map +7 -0
- package/dist/modules/sales/di.js +7 -0
- package/dist/modules/sales/di.js.map +2 -2
- package/dist/modules/sales/services/paymentOrderTotalResolver.js +52 -0
- package/dist/modules/sales/services/paymentOrderTotalResolver.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/auth/frontend/login.tsx +4 -1
- package/src/modules/customers/api/companies/[id]/route.ts +3 -4
- package/src/modules/customers/api/people/[id]/companies/enriched/route.ts +3 -2
- package/src/modules/customers/components/detail/ActiveDealCard.tsx +2 -1
- package/src/modules/customers/components/detail/CompanyKpiBar.tsx +6 -5
- package/src/modules/customers/components/detail/dashboard/helpers.ts +4 -3
- package/src/modules/customers/lib/dealStatus.ts +48 -0
- package/src/modules/payment_gateways/api/sessions/route.ts +5 -0
- package/src/modules/payment_gateways/di.ts +28 -2
- package/src/modules/payment_gateways/i18n/de.json +3 -0
- package/src/modules/payment_gateways/i18n/en.json +3 -0
- package/src/modules/payment_gateways/i18n/es.json +3 -0
- package/src/modules/payment_gateways/i18n/pl.json +3 -0
- package/src/modules/payment_gateways/lib/gateway-service.ts +14 -0
- package/src/modules/payment_gateways/lib/order-amount-reconciliation.ts +93 -0
- package/src/modules/sales/di.ts +9 -0
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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((
|
|
21
|
-
createPaymentGatewayService({
|
|
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
|
+
}
|
package/src/modules/sales/di.ts
CHANGED
|
@@ -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
|
+
}
|