Lock client prices by KYC currency
Build and deploy Docker image / build (push) Successful in 2m34s

This commit is contained in:
Ruslan Bakiev
2026-08-24 18:09:10 +07:00
parent 01736f1a16
commit 3a2bb76dbc
6 changed files with 493 additions and 67 deletions
@@ -0,0 +1,23 @@
-- AlterTable
ALTER TABLE "orders_quotation" ADD COLUMN "exchange_rate" DECIMAL(24,10),
ADD COLUMN "exchange_rate_effective_date" DATE,
ADD COLUMN "exchange_rate_locked_at" TIMESTAMP(3),
ADD COLUMN "exchange_rate_provider" VARCHAR(255),
ADD COLUMN "exchange_rate_quoted_at" TIMESTAMP(3),
ADD COLUMN "exchange_rate_snapshot_uuid" VARCHAR(100),
ADD COLUMN "pricing_country_code" VARCHAR(2),
ADD COLUMN "pricing_kyc_profile_uuid" VARCHAR(100),
ADD COLUMN "source_amount" DECIMAL(12,2),
ADD COLUMN "source_currency" VARCHAR(10);
-- AlterTable
ALTER TABLE "orders_order" ADD COLUMN "exchange_rate" DECIMAL(24,10),
ADD COLUMN "exchange_rate_effective_date" DATE,
ADD COLUMN "exchange_rate_locked_at" TIMESTAMP(3),
ADD COLUMN "exchange_rate_provider" VARCHAR(255),
ADD COLUMN "exchange_rate_quoted_at" TIMESTAMP(3),
ADD COLUMN "exchange_rate_snapshot_uuid" VARCHAR(100),
ADD COLUMN "pricing_country_code" VARCHAR(2),
ADD COLUMN "pricing_kyc_profile_uuid" VARCHAR(100),
ADD COLUMN "source_amount" DECIMAL(12,2),
ADD COLUMN "source_currency" VARCHAR(10);
+27 -7
View File
@@ -26,11 +26,11 @@ model TariffReference {
maxVolumeCbm Decimal? @map("max_volume_cbm") @db.Decimal(12, 3) maxVolumeCbm Decimal? @map("max_volume_cbm") @db.Decimal(12, 3)
minDistanceKm Int? @map("min_distance_km") minDistanceKm Int? @map("min_distance_km")
maxDistanceKm Int? @map("max_distance_km") maxDistanceKm Int? @map("max_distance_km")
amountUsd Decimal @map("amount_usd") @db.Decimal(12, 2) sourceAmount Decimal @map("amount_usd") @db.Decimal(12, 2)
minPriceUsd Decimal? @map("min_price_usd") @db.Decimal(12, 2) minSourcePrice Decimal? @map("min_price_usd") @db.Decimal(12, 2)
etaDays Int? @map("eta_days") etaDays Int? @map("eta_days")
priority Int @default(100) priority Int @default(100)
currency String @default("USD") @db.VarChar(10) sourceCurrency String @default("USD") @map("currency") @db.VarChar(10)
dmnExpression String? @map("dmn_expression") dmnExpression String? @map("dmn_expression")
notes String? notes String?
createdByUserId String? @map("created_by_user_id") @db.VarChar(255) createdByUserId String? @map("created_by_user_id") @db.VarChar(255)
@@ -76,8 +76,18 @@ model Quotation {
selectedTariff TariffReference? @relation("QuotationSelectedTariff", fields: [selectedTariffId], references: [id], onDelete: SetNull) selectedTariff TariffReference? @relation("QuotationSelectedTariff", fields: [selectedTariffId], references: [id], onDelete: SetNull)
tariffMatchSummary String? @map("tariff_match_summary") tariffMatchSummary String? @map("tariff_match_summary")
tariffSnapshot String? @map("tariff_snapshot") tariffSnapshot String? @map("tariff_snapshot")
totalAmount Decimal @default(0) @map("total_amount") @db.Decimal(12, 2) sourceAmount Decimal? @map("source_amount") @db.Decimal(12, 2)
currency String @default("USD") @db.VarChar(10) sourceCurrency String? @map("source_currency") @db.VarChar(10)
clientAmount Decimal @default(0) @map("total_amount") @db.Decimal(12, 2)
clientCurrency String @default("USD") @map("currency") @db.VarChar(10)
exchangeRate Decimal? @map("exchange_rate") @db.Decimal(24, 10)
exchangeRateSnapshotUuid String? @map("exchange_rate_snapshot_uuid") @db.VarChar(100)
exchangeRateProvider String? @map("exchange_rate_provider") @db.VarChar(255)
exchangeRateEffectiveDate DateTime? @map("exchange_rate_effective_date") @db.Date
exchangeRateQuotedAt DateTime? @map("exchange_rate_quoted_at")
exchangeRateLockedAt DateTime? @map("exchange_rate_locked_at")
pricingCountryCode String? @map("pricing_country_code") @db.VarChar(2)
pricingKycProfileUuid String? @map("pricing_kyc_profile_uuid") @db.VarChar(100)
etaDays Int? @map("eta_days") etaDays Int? @map("eta_days")
notes String? notes String?
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
@@ -116,8 +126,18 @@ model Order {
createdByUserId String? @map("created_by_user_id") @db.VarChar(255) createdByUserId String? @map("created_by_user_id") @db.VarChar(255)
name String @db.VarChar(255) name String @db.VarChar(255)
status String @default("draft") @db.VarChar(30) status String @default("draft") @db.VarChar(30)
totalAmount Decimal @default(0) @map("total_amount") @db.Decimal(12, 2) sourceAmount Decimal? @map("source_amount") @db.Decimal(12, 2)
currency String @default("USD") @db.VarChar(10) sourceCurrency String? @map("source_currency") @db.VarChar(10)
clientAmount Decimal @default(0) @map("total_amount") @db.Decimal(12, 2)
clientCurrency String @default("USD") @map("currency") @db.VarChar(10)
exchangeRate Decimal? @map("exchange_rate") @db.Decimal(24, 10)
exchangeRateSnapshotUuid String? @map("exchange_rate_snapshot_uuid") @db.VarChar(100)
exchangeRateProvider String? @map("exchange_rate_provider") @db.VarChar(255)
exchangeRateEffectiveDate DateTime? @map("exchange_rate_effective_date") @db.Date
exchangeRateQuotedAt DateTime? @map("exchange_rate_quoted_at")
exchangeRateLockedAt DateTime? @map("exchange_rate_locked_at")
pricingCountryCode String? @map("pricing_country_code") @db.VarChar(2)
pricingKycProfileUuid String? @map("pricing_kyc_profile_uuid") @db.VarChar(100)
sourceLocationUuid String? @map("source_location_uuid") @db.VarChar(100) sourceLocationUuid String? @map("source_location_uuid") @db.VarChar(100)
sourceLocationName String? @map("source_location_name") @db.VarChar(255) sourceLocationName String? @map("source_location_name") @db.VarChar(255)
sourceCountryCode String? @map("source_country_code") @db.VarChar(10) sourceCountryCode String? @map("source_country_code") @db.VarChar(10)
+77 -17
View File
@@ -1,6 +1,7 @@
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import { prisma } from './db.js' import { prisma } from './db.js'
import { lockCurrencyPrice, resolveKycPricingContext } from './pricing-context.js'
interface DemoWorkspaceAccess { interface DemoWorkspaceAccess {
teamUuid: string teamUuid: string
@@ -111,12 +112,22 @@ function stableUuid(teamUuid: string, entity: string, index: number): string {
} }
export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Promise<void> { export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Promise<void> {
const [quotationCount, orderCount] = await Promise.all([ const [quotationCount, orderCount, demoQuotationPricingCount, demoOrderPricingCount] = await Promise.all([
prisma.quotation.count({ where: { teamUuid: access.teamUuid } }), prisma.quotation.count({ where: { teamUuid: access.teamUuid } }),
prisma.order.count({ where: { teamUuid: access.teamUuid } }), prisma.order.count({ where: { teamUuid: access.teamUuid } }),
prisma.quotation.count({
where: { teamUuid: access.teamUuid, notes: demoNote, sourceAmount: null },
}),
prisma.order.count({
where: { teamUuid: access.teamUuid, notes: demoNote, sourceAmount: null },
}),
]) ])
const needsCalculations = quotationCount === 0 const needsCalculations = quotationCount === 0
const needsOrders = orderCount === 0 const needsOrders = orderCount === 0
const needsPricingBackfill = demoQuotationPricingCount > 0 || demoOrderPricingCount > 0
const kyc = needsCalculations || needsOrders || needsPricingBackfill
? await resolveKycPricingContext(access.userId)
: null
for (const [index, route] of demoRoutes.entries()) { for (const [index, route] of demoRoutes.entries()) {
const createsOrder = index < 2 const createsOrder = index < 2
@@ -132,11 +143,11 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom
status: 'active', status: 'active',
sourceCountryCode: route.sourceCountryCode, sourceCountryCode: route.sourceCountryCode,
destinationCountryCode: route.destinationCountryCode, destinationCountryCode: route.destinationCountryCode,
amountUsd: new Prisma.Decimal(route.amountUsd), sourceAmount: new Prisma.Decimal(route.amountUsd),
minPriceUsd: new Prisma.Decimal(route.amountUsd), minSourcePrice: new Prisma.Decimal(route.amountUsd),
etaDays: route.etaDays, etaDays: route.etaDays,
priority: 50 + index, priority: 50 + index,
currency: 'USD', sourceCurrency: 'USD',
notes: demoNote, notes: demoNote,
}, },
update: { update: {
@@ -150,16 +161,25 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom
tareTypeCode: null, tareTypeCode: null,
sourceCountryCode: route.sourceCountryCode, sourceCountryCode: route.sourceCountryCode,
destinationCountryCode: route.destinationCountryCode, destinationCountryCode: route.destinationCountryCode,
amountUsd: new Prisma.Decimal(route.amountUsd), sourceAmount: new Prisma.Decimal(route.amountUsd),
minPriceUsd: new Prisma.Decimal(route.amountUsd), minSourcePrice: new Prisma.Decimal(route.amountUsd),
etaDays: route.etaDays, etaDays: route.etaDays,
priority: 50 + index, priority: 50 + index,
currency: 'USD', sourceCurrency: 'USD',
notes: demoNote, notes: demoNote,
}, },
}) })
if (!needsCalculations && !(needsOrders && createsOrder)) continue if (!needsCalculations && !(needsOrders && createsOrder) && !needsPricingBackfill) continue
if (kyc === null) throw new Error('KYC pricing context was not resolved for demo data')
const price = await lockCurrencyPrice({
sourceAmount: route.amountUsd,
sourceCurrency: 'USD',
pricingCountryCode: kyc.countryCode,
})
const pricingEffectiveDate = new Date(`${price.rateEffectiveDate}T00:00:00.000Z`)
const pricingQuotedAt = new Date(price.rateQuotedAt)
const pricingLockedAt = new Date(price.rateLockedAt)
const quotationUuid = stableUuid(access.teamUuid, 'demo-quotation', index) const quotationUuid = stableUuid(access.teamUuid, 'demo-quotation', index)
const quotation = await prisma.quotation.upsert({ const quotation = await prisma.quotation.upsert({
@@ -188,8 +208,18 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom
routeDistanceKm: route.routeDistanceKm, routeDistanceKm: route.routeDistanceKm,
selectedTariffId: tariff.id, selectedTariffId: tariff.id,
tariffMatchSummary: `Demo tariff: ${route.title}`, tariffMatchSummary: `Demo tariff: ${route.title}`,
totalAmount: new Prisma.Decimal(route.amountUsd), sourceAmount: new Prisma.Decimal(price.sourceAmount),
currency: 'USD', sourceCurrency: price.sourceCurrency,
clientAmount: new Prisma.Decimal(price.clientAmount),
clientCurrency: price.clientCurrency,
exchangeRate: new Prisma.Decimal(price.exchangeRate),
exchangeRateSnapshotUuid: price.rateSnapshotUuid,
exchangeRateProvider: price.provider,
exchangeRateEffectiveDate: pricingEffectiveDate,
exchangeRateQuotedAt: pricingQuotedAt,
exchangeRateLockedAt: pricingLockedAt,
pricingCountryCode: price.pricingCountryCode,
pricingKycProfileUuid: kyc.profileUuid,
etaDays: route.etaDays, etaDays: route.etaDays,
notes: demoNote, notes: demoNote,
}, },
@@ -197,14 +227,24 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom
teamUuid: access.teamUuid, teamUuid: access.teamUuid,
createdByUserId: access.userId, createdByUserId: access.userId,
selectedTariffId: tariff.id, selectedTariffId: tariff.id,
totalAmount: new Prisma.Decimal(route.amountUsd), sourceAmount: new Prisma.Decimal(price.sourceAmount),
currency: 'USD', sourceCurrency: price.sourceCurrency,
clientAmount: new Prisma.Decimal(price.clientAmount),
clientCurrency: price.clientCurrency,
exchangeRate: new Prisma.Decimal(price.exchangeRate),
exchangeRateSnapshotUuid: price.rateSnapshotUuid,
exchangeRateProvider: price.provider,
exchangeRateEffectiveDate: pricingEffectiveDate,
exchangeRateQuotedAt: pricingQuotedAt,
exchangeRateLockedAt: pricingLockedAt,
pricingCountryCode: price.pricingCountryCode,
pricingKycProfileUuid: kyc.profileUuid,
etaDays: route.etaDays, etaDays: route.etaDays,
notes: demoNote, notes: demoNote,
}, },
}) })
if (!needsOrders || !createsOrder) continue if ((!needsOrders && !needsPricingBackfill) || !createsOrder) continue
await prisma.order.upsert({ await prisma.order.upsert({
where: { uuid: stableUuid(access.teamUuid, 'demo-order', index) }, where: { uuid: stableUuid(access.teamUuid, 'demo-order', index) },
create: { create: {
@@ -214,8 +254,18 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom
createdByUserId: access.userId, createdByUserId: access.userId,
name: route.title, name: route.title,
status: index === 0 ? 'in_transit' : 'processing', status: index === 0 ? 'in_transit' : 'processing',
totalAmount: new Prisma.Decimal(route.amountUsd), sourceAmount: new Prisma.Decimal(price.sourceAmount),
currency: 'USD', sourceCurrency: price.sourceCurrency,
clientAmount: new Prisma.Decimal(price.clientAmount),
clientCurrency: price.clientCurrency,
exchangeRate: new Prisma.Decimal(price.exchangeRate),
exchangeRateSnapshotUuid: price.rateSnapshotUuid,
exchangeRateProvider: price.provider,
exchangeRateEffectiveDate: pricingEffectiveDate,
exchangeRateQuotedAt: pricingQuotedAt,
exchangeRateLockedAt: pricingLockedAt,
pricingCountryCode: price.pricingCountryCode,
pricingKycProfileUuid: kyc.profileUuid,
sourceLocationName: route.sourceLocationName, sourceLocationName: route.sourceLocationName,
sourceCountryCode: route.sourceCountryCode, sourceCountryCode: route.sourceCountryCode,
sourceLatitude: route.sourceLatitude, sourceLatitude: route.sourceLatitude,
@@ -231,8 +281,18 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom
teamUuid: access.teamUuid, teamUuid: access.teamUuid,
createdByUserId: access.userId, createdByUserId: access.userId,
status: index === 0 ? 'in_transit' : 'processing', status: index === 0 ? 'in_transit' : 'processing',
totalAmount: new Prisma.Decimal(route.amountUsd), sourceAmount: new Prisma.Decimal(price.sourceAmount),
currency: 'USD', sourceCurrency: price.sourceCurrency,
clientAmount: new Prisma.Decimal(price.clientAmount),
clientCurrency: price.clientCurrency,
exchangeRate: new Prisma.Decimal(price.exchangeRate),
exchangeRateSnapshotUuid: price.rateSnapshotUuid,
exchangeRateProvider: price.provider,
exchangeRateEffectiveDate: pricingEffectiveDate,
exchangeRateQuotedAt: pricingQuotedAt,
exchangeRateLockedAt: pricingLockedAt,
pricingCountryCode: price.pricingCountryCode,
pricingKycProfileUuid: kyc.profileUuid,
notes: demoNote, notes: demoNote,
}, },
}) })
+110
View File
@@ -0,0 +1,110 @@
type GraphqlEnvelope<T> = {
data?: T
errors?: Array<{ message?: string }>
}
export type KycPricingContext = {
userId: string
profileUuid: string
countryCode: string
resolvedAt: string
}
export type LockedCurrencyPrice = {
sourceAmount: number
sourceCurrency: string
clientAmount: number
clientCurrency: string
exchangeRate: number
rateSnapshotUuid: string | null
rateEffectiveDate: string
rateQuotedAt: string
rateLockedAt: string
provider: string
attributionUrl: string | null
pricingCountryCode: string
}
const kycM2mEndpoint =
process.env.KYC_M2M_GRAPHQL_ENDPOINT ?? 'https://kyc.optovia.ru/graphql/m2m'
const exchangeM2mEndpoint =
process.env.EXCHANGE_M2M_GRAPHQL_ENDPOINT ?? 'https://exchange.optovia.ru/graphql/m2m'
async function requestGraphql<T>(
endpoint: string,
query: string,
variables: Record<string, unknown>,
): Promise<T> {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query, variables }),
})
if (!response.ok) {
throw new Error(`Pricing dependency request failed: ${response.status} ${endpoint}`)
}
const envelope = (await response.json()) as GraphqlEnvelope<T>
if (envelope.errors && envelope.errors.length > 0) {
throw new Error(
envelope.errors.map(error => error.message ?? 'Unknown GraphQL error').join('\n'),
)
}
if (envelope.data === undefined) {
throw new Error(`Pricing dependency returned no data: ${endpoint}`)
}
return envelope.data
}
export async function resolveKycPricingContext(
userId: string,
): Promise<KycPricingContext> {
const data = await requestGraphql<{
pricingContextForUser: KycPricingContext | null
}>(
kycM2mEndpoint,
`query OrdersKycPricingContext($userId: String!) {
pricingContextForUser(userId: $userId) {
userId
profileUuid
countryCode
resolvedAt
}
}`,
{ userId },
)
if (data.pricingContextForUser === null) {
throw new Error(`Active KYC pricing context is required for user ${userId}`)
}
return data.pricingContextForUser
}
export async function lockCurrencyPrice(input: {
sourceAmount: number
sourceCurrency: string
pricingCountryCode: string
rateDate?: string
}): Promise<LockedCurrencyPrice> {
const data = await requestGraphql<{
lockCurrencyPrice: LockedCurrencyPrice
}>(
exchangeM2mEndpoint,
`mutation OrdersLockCurrencyPrice($input: LockCurrencyPriceInput!) {
lockCurrencyPrice(input: $input) {
sourceAmount
sourceCurrency
clientAmount
clientCurrency
exchangeRate
rateSnapshotUuid
rateEffectiveDate
rateQuotedAt
rateLockedAt
provider
attributionUrl
pricingCountryCode
}
}`,
{ input },
)
return data.lockCurrencyPrice
}
+250 -37
View File
@@ -9,6 +9,12 @@ import {
import { requireScopes, type AuthContext } from '../auth.js' import { requireScopes, type AuthContext } from '../auth.js'
import { prisma } from '../db.js' import { prisma } from '../db.js'
import { ensureDemoWorkspaceData } from '../demo-workspace.js' import { ensureDemoWorkspaceData } from '../demo-workspace.js'
import {
lockCurrencyPrice,
resolveKycPricingContext,
type KycPricingContext,
type LockedCurrencyPrice,
} from '../pricing-context.js'
const quotationInclude = { const quotationInclude = {
selectedTariff: true, selectedTariff: true,
@@ -30,7 +36,7 @@ interface TariffMatchResult {
tariff: PrismaTariffReference tariff: PrismaTariffReference
score: number score: number
reasons: string[] reasons: string[]
projectedAmount: number projectedSourceAmount: number
projectedEtaDays: number | null projectedEtaDays: number | null
} }
@@ -102,8 +108,20 @@ export const teamTypeDefs = `#graphql
teamUuid: String teamUuid: String
userId: String userId: String
status: String status: String
totalAmount: Float sourceAmount: Float
currency: String sourceCurrency: String
clientAmount: Float!
clientCurrency: String!
exchangeRate: Float
exchangeRateSnapshotUuid: String
exchangeRateProvider: String
exchangeRateEffectiveDate: String
exchangeRateQuotedAt: String
exchangeRateLockedAt: String
pricingCountryCode: String
pricingKycProfileUuid: String
totalAmount: Float @deprecated(reason: "Use clientAmount")
currency: String @deprecated(reason: "Use clientCurrency")
sourceCountryCode: String sourceCountryCode: String
sourceLocationUuid: String sourceLocationUuid: String
sourceLocationName: String sourceLocationName: String
@@ -139,11 +157,14 @@ export const teamTypeDefs = `#graphql
maxVolumeCbm: Float maxVolumeCbm: Float
minDistanceKm: Int minDistanceKm: Int
maxDistanceKm: Int maxDistanceKm: Int
amountUsd: Float! sourceAmount: Float!
minPriceUsd: Float sourceCurrency: String!
minSourcePrice: Float
amountUsd: Float! @deprecated(reason: "Use sourceAmount")
minPriceUsd: Float @deprecated(reason: "Use minSourcePrice")
etaDays: Int etaDays: Int
priority: Int! priority: Int!
currency: String! currency: String! @deprecated(reason: "Use sourceCurrency")
dmnExpression: String dmnExpression: String
notes: String notes: String
createdByUserId: String createdByUserId: String
@@ -191,8 +212,20 @@ export const teamTypeDefs = `#graphql
selectedTariff: TariffReference selectedTariff: TariffReference
tariffMatchSummary: String tariffMatchSummary: String
tariffSnapshot: String tariffSnapshot: String
totalAmount: Float! sourceAmount: Float
currency: String! sourceCurrency: String
clientAmount: Float!
clientCurrency: String!
exchangeRate: Float
exchangeRateSnapshotUuid: String
exchangeRateProvider: String
exchangeRateEffectiveDate: String
exchangeRateQuotedAt: String
exchangeRateLockedAt: String
pricingCountryCode: String
pricingKycProfileUuid: String
totalAmount: Float! @deprecated(reason: "Use clientAmount")
currency: String! @deprecated(reason: "Use clientCurrency")
etaDays: Int etaDays: Int
notes: String notes: String
createdAt: String! createdAt: String!
@@ -204,7 +237,12 @@ export const teamTypeDefs = `#graphql
tariffReference: TariffReference! tariffReference: TariffReference!
score: Int! score: Int!
reasons: [String!]! reasons: [String!]!
projectedAmount: Float! projectedSourceAmount: Float!
sourceCurrency: String!
projectedClientAmount: Float!
clientCurrency: String!
exchangeRate: Float!
projectedAmount: Float! @deprecated(reason: "Use projectedClientAmount")
projectedEtaDays: Int projectedEtaDays: Int
isSelected: Boolean! isSelected: Boolean!
} }
@@ -226,7 +264,10 @@ export const teamTypeDefs = `#graphql
maxVolumeCbm: Float maxVolumeCbm: Float
minDistanceKm: Int minDistanceKm: Int
maxDistanceKm: Int maxDistanceKm: Int
amountUsd: Float! sourceAmount: Float
sourceCurrency: String
minSourcePrice: Float
amountUsd: Float
minPriceUsd: Float minPriceUsd: Float
etaDays: Int etaDays: Int
priority: Int priority: Int
@@ -252,6 +293,9 @@ export const teamTypeDefs = `#graphql
maxVolumeCbm: Float maxVolumeCbm: Float
minDistanceKm: Int minDistanceKm: Int
maxDistanceKm: Int maxDistanceKm: Int
sourceAmount: Float
sourceCurrency: String
minSourcePrice: Float
amountUsd: Float amountUsd: Float
minPriceUsd: Float minPriceUsd: Float
etaDays: Int etaDays: Int
@@ -473,11 +517,14 @@ function mapTariffReference(item: PrismaTariffReference) {
maxVolumeCbm: numberFromDecimal(item.maxVolumeCbm), maxVolumeCbm: numberFromDecimal(item.maxVolumeCbm),
minDistanceKm: item.minDistanceKm, minDistanceKm: item.minDistanceKm,
maxDistanceKm: item.maxDistanceKm, maxDistanceKm: item.maxDistanceKm,
amountUsd: Number(item.amountUsd), sourceAmount: Number(item.sourceAmount),
minPriceUsd: numberFromDecimal(item.minPriceUsd), sourceCurrency: item.sourceCurrency,
minSourcePrice: numberFromDecimal(item.minSourcePrice),
amountUsd: Number(item.sourceAmount),
minPriceUsd: numberFromDecimal(item.minSourcePrice),
etaDays: item.etaDays, etaDays: item.etaDays,
priority: item.priority, priority: item.priority,
currency: item.currency, currency: item.sourceCurrency,
dmnExpression: item.dmnExpression, dmnExpression: item.dmnExpression,
notes: item.notes, notes: item.notes,
createdByUserId: item.createdByUserId, createdByUserId: item.createdByUserId,
@@ -529,8 +576,20 @@ function mapQuotation(item: QuotationWithRelations) {
selectedTariff: item.selectedTariff ? mapTariffReference(item.selectedTariff) : null, selectedTariff: item.selectedTariff ? mapTariffReference(item.selectedTariff) : null,
tariffMatchSummary: item.tariffMatchSummary, tariffMatchSummary: item.tariffMatchSummary,
tariffSnapshot: item.tariffSnapshot, tariffSnapshot: item.tariffSnapshot,
totalAmount: Number(item.totalAmount), sourceAmount: numberFromDecimal(item.sourceAmount),
currency: item.currency, sourceCurrency: item.sourceCurrency,
clientAmount: Number(item.clientAmount),
clientCurrency: item.clientCurrency,
exchangeRate: numberFromDecimal(item.exchangeRate),
exchangeRateSnapshotUuid: item.exchangeRateSnapshotUuid,
exchangeRateProvider: item.exchangeRateProvider,
exchangeRateEffectiveDate: item.exchangeRateEffectiveDate?.toISOString().slice(0, 10) ?? null,
exchangeRateQuotedAt: item.exchangeRateQuotedAt?.toISOString() ?? null,
exchangeRateLockedAt: item.exchangeRateLockedAt?.toISOString() ?? null,
pricingCountryCode: item.pricingCountryCode,
pricingKycProfileUuid: item.pricingKycProfileUuid,
totalAmount: Number(item.clientAmount),
currency: item.clientCurrency,
etaDays: item.etaDays, etaDays: item.etaDays,
notes: item.notes, notes: item.notes,
createdAt: item.createdAt.toISOString(), createdAt: item.createdAt.toISOString(),
@@ -550,8 +609,20 @@ function mapLocalOrder(item: OrderWithRelations) {
teamUuid: item.teamUuid, teamUuid: item.teamUuid,
userId: item.createdByUserId, userId: item.createdByUserId,
status: item.status, status: item.status,
totalAmount: Number(item.totalAmount), sourceAmount: numberFromDecimal(item.sourceAmount),
currency: item.currency, sourceCurrency: item.sourceCurrency,
clientAmount: Number(item.clientAmount),
clientCurrency: item.clientCurrency,
exchangeRate: numberFromDecimal(item.exchangeRate),
exchangeRateSnapshotUuid: item.exchangeRateSnapshotUuid,
exchangeRateProvider: item.exchangeRateProvider,
exchangeRateEffectiveDate: item.exchangeRateEffectiveDate?.toISOString().slice(0, 10) ?? null,
exchangeRateQuotedAt: item.exchangeRateQuotedAt?.toISOString() ?? null,
exchangeRateLockedAt: item.exchangeRateLockedAt?.toISOString() ?? null,
pricingCountryCode: item.pricingCountryCode,
pricingKycProfileUuid: item.pricingKycProfileUuid,
totalAmount: Number(item.clientAmount),
currency: item.clientCurrency,
sourceCountryCode: item.sourceCountryCode, sourceCountryCode: item.sourceCountryCode,
sourceLocationUuid: item.sourceLocationUuid, sourceLocationUuid: item.sourceLocationUuid,
sourceLocationName: item.sourceLocationName, sourceLocationName: item.sourceLocationName,
@@ -615,7 +686,24 @@ function requiredString(value: unknown, label: string): string {
return next return next
} }
function compatibleInput(
input: Record<string, unknown>,
primaryKey: string,
legacyKey: string,
): unknown {
const hasPrimary = hasOwn(input, primaryKey)
const hasLegacy = hasOwn(input, legacyKey)
if (hasPrimary && hasLegacy && String(input[primaryKey]) !== String(input[legacyKey])) {
throw new GraphQLError(`${primaryKey} and deprecated ${legacyKey} must match`)
}
return hasPrimary ? input[primaryKey] : input[legacyKey]
}
function buildCreateTariffData(input: CreateTariffReferenceInput, ctx: { teamUuid: string; userId: string }): Prisma.TariffReferenceCreateInput { function buildCreateTariffData(input: CreateTariffReferenceInput, ctx: { teamUuid: string; userId: string }): Prisma.TariffReferenceCreateInput {
const sourceAmount = normalizeDecimal(compatibleInput(input, 'sourceAmount', 'amountUsd'))
if (sourceAmount === null) {
throw new GraphQLError('sourceAmount is required')
}
return { return {
teamUuid: ctx.teamUuid, teamUuid: ctx.teamUuid,
createdByUserId: ctx.userId, createdByUserId: ctx.userId,
@@ -635,11 +723,11 @@ function buildCreateTariffData(input: CreateTariffReferenceInput, ctx: { teamUui
maxVolumeCbm: normalizeDecimal(input.maxVolumeCbm), maxVolumeCbm: normalizeDecimal(input.maxVolumeCbm),
minDistanceKm: normalizeInteger(input.minDistanceKm), minDistanceKm: normalizeInteger(input.minDistanceKm),
maxDistanceKm: normalizeInteger(input.maxDistanceKm), maxDistanceKm: normalizeInteger(input.maxDistanceKm),
amountUsd: normalizeDecimal(input.amountUsd) ?? new Prisma.Decimal(0), sourceAmount,
minPriceUsd: normalizeDecimal(input.minPriceUsd), minSourcePrice: normalizeDecimal(compatibleInput(input, 'minSourcePrice', 'minPriceUsd')),
etaDays: normalizeInteger(input.etaDays), etaDays: normalizeInteger(input.etaDays),
priority: normalizeInteger(input.priority) ?? 100, priority: normalizeInteger(input.priority) ?? 100,
currency: normalizeUpper(input.currency) ?? 'USD', sourceCurrency: normalizeUpper(compatibleInput(input, 'sourceCurrency', 'currency')) ?? 'USD',
dmnExpression: normalizeString(input.dmnExpression), dmnExpression: normalizeString(input.dmnExpression),
notes: normalizeString(input.notes), notes: normalizeString(input.notes),
} }
@@ -664,11 +752,23 @@ function buildUpdateTariffData(input: UpdateTariffReferenceInput): Prisma.Tariff
if (hasOwn(input, 'maxVolumeCbm')) data.maxVolumeCbm = normalizeDecimal(input.maxVolumeCbm) if (hasOwn(input, 'maxVolumeCbm')) data.maxVolumeCbm = normalizeDecimal(input.maxVolumeCbm)
if (hasOwn(input, 'minDistanceKm')) data.minDistanceKm = normalizeInteger(input.minDistanceKm) if (hasOwn(input, 'minDistanceKm')) data.minDistanceKm = normalizeInteger(input.minDistanceKm)
if (hasOwn(input, 'maxDistanceKm')) data.maxDistanceKm = normalizeInteger(input.maxDistanceKm) if (hasOwn(input, 'maxDistanceKm')) data.maxDistanceKm = normalizeInteger(input.maxDistanceKm)
if (hasOwn(input, 'amountUsd')) data.amountUsd = normalizeRequiredUpdateDecimal(input.amountUsd) if (hasOwn(input, 'sourceAmount') || hasOwn(input, 'amountUsd')) {
if (hasOwn(input, 'minPriceUsd')) data.minPriceUsd = normalizeDecimal(input.minPriceUsd) data.sourceAmount = normalizeRequiredUpdateDecimal(
compatibleInput(input, 'sourceAmount', 'amountUsd'),
)
}
if (hasOwn(input, 'minSourcePrice') || hasOwn(input, 'minPriceUsd')) {
data.minSourcePrice = normalizeDecimal(
compatibleInput(input, 'minSourcePrice', 'minPriceUsd'),
)
}
if (hasOwn(input, 'etaDays')) data.etaDays = normalizeInteger(input.etaDays) if (hasOwn(input, 'etaDays')) data.etaDays = normalizeInteger(input.etaDays)
if (hasOwn(input, 'priority')) data.priority = normalizeRequiredUpdateInteger(input.priority) if (hasOwn(input, 'priority')) data.priority = normalizeRequiredUpdateInteger(input.priority)
if (hasOwn(input, 'currency')) data.currency = normalizeRequiredUpdateUpper(input.currency) if (hasOwn(input, 'sourceCurrency') || hasOwn(input, 'currency')) {
data.sourceCurrency = normalizeRequiredUpdateUpper(
compatibleInput(input, 'sourceCurrency', 'currency'),
)
}
if (hasOwn(input, 'dmnExpression')) data.dmnExpression = normalizeString(input.dmnExpression) if (hasOwn(input, 'dmnExpression')) data.dmnExpression = normalizeString(input.dmnExpression)
if (hasOwn(input, 'notes')) data.notes = normalizeString(input.notes) if (hasOwn(input, 'notes')) data.notes = normalizeString(input.notes)
@@ -798,12 +898,15 @@ function computeTariffMatches(
const rangeScoreParts = rangeChecks.filter((item): item is number => item != null) const rangeScoreParts = rangeChecks.filter((item): item is number => item != null)
score += rangeScoreParts.reduce((sum, item) => sum + item, 0) score += rangeScoreParts.reduce((sum, item) => sum + item, 0)
const projectedAmount = Math.max(Number(tariff.amountUsd), numberFromDecimal(tariff.minPriceUsd) ?? 0) const projectedSourceAmount = Math.max(
Number(tariff.sourceAmount),
numberFromDecimal(tariff.minSourcePrice) ?? 0,
)
matches.push({ matches.push({
tariff, tariff,
score, score,
reasons, reasons,
projectedAmount, projectedSourceAmount,
projectedEtaDays: tariff.etaDays, projectedEtaDays: tariff.etaDays,
}) })
} }
@@ -869,6 +972,63 @@ function actorLabel(ctx: { userId: string; teamUuid: string }): string {
return `user:${ctx.userId}@${ctx.teamUuid}` return `user:${ctx.userId}@${ctx.teamUuid}`
} }
async function lockTariffPrice(
tariff: PrismaTariffReference,
sourceAmount: number,
userId: string,
): Promise<{ kyc: KycPricingContext; price: LockedCurrencyPrice }> {
const kyc = await resolveKycPricingContext(userId)
const price = await lockCurrencyPrice({
sourceAmount,
sourceCurrency: tariff.sourceCurrency,
pricingCountryCode: kyc.countryCode,
})
return { kyc, price }
}
function quotationPricingUserId(quotation: PrismaQuotation): string {
if (!quotation.createdByUserId) {
throw new GraphQLError('Quotation owner is required to resolve KYC pricing currency')
}
return quotation.createdByUserId
}
function lockedQuotationPriceData(
locked: { kyc: KycPricingContext; price: LockedCurrencyPrice } | null,
currentClientCurrency: string,
): Prisma.QuotationUncheckedUpdateInput {
if (locked === null) {
return {
sourceAmount: null,
sourceCurrency: null,
clientAmount: new Prisma.Decimal(0),
clientCurrency: currentClientCurrency,
exchangeRate: null,
exchangeRateSnapshotUuid: null,
exchangeRateProvider: null,
exchangeRateEffectiveDate: null,
exchangeRateQuotedAt: null,
exchangeRateLockedAt: null,
pricingCountryCode: null,
pricingKycProfileUuid: null,
}
}
return {
sourceAmount: new Prisma.Decimal(locked.price.sourceAmount),
sourceCurrency: locked.price.sourceCurrency,
clientAmount: new Prisma.Decimal(locked.price.clientAmount),
clientCurrency: locked.price.clientCurrency,
exchangeRate: new Prisma.Decimal(locked.price.exchangeRate),
exchangeRateSnapshotUuid: locked.price.rateSnapshotUuid,
exchangeRateProvider: locked.price.provider,
exchangeRateEffectiveDate: new Date(`${locked.price.rateEffectiveDate}T00:00:00.000Z`),
exchangeRateQuotedAt: new Date(locked.price.rateQuotedAt),
exchangeRateLockedAt: new Date(locked.price.rateLockedAt),
pricingCountryCode: locked.price.pricingCountryCode,
pricingKycProfileUuid: locked.kyc.profileUuid,
}
}
async function refreshQuotationSelection(quotationUuid: string, teamUuid: string, actor: { userId: string; label: string }) { async function refreshQuotationSelection(quotationUuid: string, teamUuid: string, actor: { userId: string; label: string }) {
const quotation = await prisma.quotation.findFirst({ const quotation = await prisma.quotation.findFirst({
where: { where: {
@@ -889,6 +1049,13 @@ async function refreshQuotationSelection(quotationUuid: string, teamUuid: string
const matches = computeTariffMatches(quotation, tariffs) const matches = computeTariffMatches(quotation, tariffs)
const bestMatch = matches[0] ?? null const bestMatch = matches[0] ?? null
const locked = bestMatch
? await lockTariffPrice(
bestMatch.tariff,
bestMatch.projectedSourceAmount,
quotationPricingUserId(quotation),
)
: null
const tariffMatchSummary = bestMatch const tariffMatchSummary = bestMatch
? `Matched ${bestMatch.tariff.name}; ${bestMatch.reasons.join(', ')}` ? `Matched ${bestMatch.tariff.name}; ${bestMatch.reasons.join(', ')}`
@@ -903,9 +1070,12 @@ async function refreshQuotationSelection(quotationUuid: string, teamUuid: string
tariffReferenceUuid: item.tariff.uuid, tariffReferenceUuid: item.tariff.uuid,
score: item.score, score: item.score,
reasons: item.reasons, reasons: item.reasons,
projectedAmount: item.projectedAmount, projectedSourceAmount: item.projectedSourceAmount,
sourceCurrency: item.tariff.sourceCurrency,
projectedEtaDays: item.projectedEtaDays, projectedEtaDays: item.projectedEtaDays,
})), })),
lockedPrice: locked?.price ?? null,
pricingKycProfileUuid: locked?.kyc.profileUuid ?? null,
}) })
const updated = await prisma.quotation.update({ const updated = await prisma.quotation.update({
@@ -914,8 +1084,7 @@ async function refreshQuotationSelection(quotationUuid: string, teamUuid: string
}, },
data: { data: {
selectedTariffId: bestMatch?.tariff.id ?? null, selectedTariffId: bestMatch?.tariff.id ?? null,
totalAmount: new Prisma.Decimal(bestMatch?.projectedAmount ?? 0), ...lockedQuotationPriceData(locked, quotation.clientCurrency),
currency: bestMatch?.tariff.currency ?? quotation.currency,
etaDays: bestMatch?.projectedEtaDays ?? null, etaDays: bestMatch?.projectedEtaDays ?? null,
tariffMatchSummary, tariffMatchSummary,
tariffSnapshot, tariffSnapshot,
@@ -1062,14 +1231,29 @@ export const teamResolvers = {
}) })
const matches = computeTariffMatches(quotation, tariffs) const matches = computeTariffMatches(quotation, tariffs)
return matches.map(item => ({ const kyc = await resolveKycPricingContext(quotationPricingUserId(quotation))
return Promise.all(
matches.map(async item => {
const price = await lockCurrencyPrice({
sourceAmount: item.projectedSourceAmount,
sourceCurrency: item.tariff.sourceCurrency,
pricingCountryCode: kyc.countryCode,
})
return {
tariffReference: mapTariffReference(item.tariff), tariffReference: mapTariffReference(item.tariff),
score: item.score, score: item.score,
reasons: item.reasons, reasons: item.reasons,
projectedAmount: item.projectedAmount, projectedSourceAmount: item.projectedSourceAmount,
sourceCurrency: item.tariff.sourceCurrency,
projectedClientAmount: price.clientAmount,
clientCurrency: price.clientCurrency,
exchangeRate: price.exchangeRate,
projectedAmount: price.clientAmount,
projectedEtaDays: item.projectedEtaDays, projectedEtaDays: item.projectedEtaDays,
isSelected: quotation.selectedTariff?.uuid === item.tariff.uuid, isSelected: quotation.selectedTariff?.uuid === item.tariff.uuid,
})) }
}),
)
}, },
}, },
@@ -1166,15 +1350,22 @@ export const teamResolvers = {
const actor = { userId: access.userId, label: actorLabel(teamAccess) } const actor = { userId: access.userId, label: actorLabel(teamAccess) }
const tariff = await loadTariffOrThrow(args.tariffReferenceUuid, quotation.teamUuid) const tariff = await loadTariffOrThrow(args.tariffReferenceUuid, quotation.teamUuid)
const projectedAmount = Math.max(Number(tariff.amountUsd), numberFromDecimal(tariff.minPriceUsd) ?? 0) const projectedSourceAmount = Math.max(
Number(tariff.sourceAmount),
numberFromDecimal(tariff.minSourcePrice) ?? 0,
)
const locked = await lockTariffPrice(
tariff,
projectedSourceAmount,
quotationPricingUserId(quotation),
)
await prisma.quotation.update({ await prisma.quotation.update({
where: { where: {
id: quotation.id, id: quotation.id,
}, },
data: { data: {
selectedTariffId: tariff.id, selectedTariffId: tariff.id,
totalAmount: new Prisma.Decimal(projectedAmount), ...lockedQuotationPriceData(locked, quotation.clientCurrency),
currency: tariff.currency,
etaDays: tariff.etaDays, etaDays: tariff.etaDays,
tariffMatchSummary: `Manually selected ${tariff.name}`, tariffMatchSummary: `Manually selected ${tariff.name}`,
tariffSnapshot: JSON.stringify({ tariffSnapshot: JSON.stringify({
@@ -1182,6 +1373,8 @@ export const teamResolvers = {
selectedTariffUuid: tariff.uuid, selectedTariffUuid: tariff.uuid,
selectedAt: new Date().toISOString(), selectedAt: new Date().toISOString(),
selectionMode: 'manual', selectionMode: 'manual',
lockedPrice: locked.price,
pricingKycProfileUuid: locked.kyc.profileUuid,
}), }),
status: quotation.status === 'converted' ? quotation.status : 'priced', status: quotation.status === 'converted' ? quotation.status : 'priced',
}, },
@@ -1216,16 +1409,36 @@ export const teamResolvers = {
if (!quotation.selectedTariff) { if (!quotation.selectedTariff) {
throw new GraphQLError('Quotation must have a selected tariff before order creation') throw new GraphQLError('Quotation must have a selected tariff before order creation')
} }
if (
quotation.sourceAmount === null ||
quotation.sourceCurrency === null ||
quotation.exchangeRate === null ||
quotation.exchangeRateLockedAt === null
) {
throw new GraphQLError(
'Quotation must have a locked currency price before order creation',
)
}
const order = await prisma.order.create({ const order = await prisma.order.create({
data: { data: {
teamUuid: quotation.teamUuid, teamUuid: quotation.teamUuid,
quotationId: quotation.id, quotationId: quotation.id,
createdByUserId: access.userId, createdByUserId: quotation.createdByUserId ?? access.userId,
name: quotation.title.startsWith('Order') ? quotation.title : `Order for ${quotation.title}`, name: quotation.title.startsWith('Order') ? quotation.title : `Order for ${quotation.title}`,
status: 'created', status: 'created',
totalAmount: quotation.totalAmount, sourceAmount: quotation.sourceAmount,
currency: quotation.currency, sourceCurrency: quotation.sourceCurrency,
clientAmount: quotation.clientAmount,
clientCurrency: quotation.clientCurrency,
exchangeRate: quotation.exchangeRate,
exchangeRateSnapshotUuid: quotation.exchangeRateSnapshotUuid,
exchangeRateProvider: quotation.exchangeRateProvider,
exchangeRateEffectiveDate: quotation.exchangeRateEffectiveDate,
exchangeRateQuotedAt: quotation.exchangeRateQuotedAt,
exchangeRateLockedAt: quotation.exchangeRateLockedAt,
pricingCountryCode: quotation.pricingCountryCode,
pricingKycProfileUuid: quotation.pricingKycProfileUuid,
sourceLocationUuid: quotation.sourceLocationUuid, sourceLocationUuid: quotation.sourceLocationUuid,
sourceLocationName: quotation.sourceLocationName, sourceLocationName: quotation.sourceLocationName,
sourceCountryCode: quotation.sourceCountryCode, sourceCountryCode: quotation.sourceCountryCode,