From 3a2bb76dbc841b44ca29c993397056bbb83f13be Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:09:10 +0700 Subject: [PATCH] Lock client prices by KYC currency --- graphql-contracts | 2 +- .../migration.sql | 23 ++ prisma/schema.prisma | 34 +- src/demo-workspace.ts | 94 +++++- src/pricing-context.ts | 110 +++++++ src/schemas/team.ts | 297 +++++++++++++++--- 6 files changed, 493 insertions(+), 67 deletions(-) create mode 100644 prisma/migrations/3_add_locked_currency_prices/migration.sql create mode 100644 src/pricing-context.ts diff --git a/graphql-contracts b/graphql-contracts index 88e1aa5..48cd334 160000 --- a/graphql-contracts +++ b/graphql-contracts @@ -1 +1 @@ -Subproject commit 88e1aa58bf7d7725416e74d8cff055c101b52b29 +Subproject commit 48cd334d24af33107630f0bacd84e4b5af81ecb1 diff --git a/prisma/migrations/3_add_locked_currency_prices/migration.sql b/prisma/migrations/3_add_locked_currency_prices/migration.sql new file mode 100644 index 0000000..aa580d1 --- /dev/null +++ b/prisma/migrations/3_add_locked_currency_prices/migration.sql @@ -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); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e9768c4..8e1649a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,11 +26,11 @@ model TariffReference { maxVolumeCbm Decimal? @map("max_volume_cbm") @db.Decimal(12, 3) minDistanceKm Int? @map("min_distance_km") maxDistanceKm Int? @map("max_distance_km") - amountUsd Decimal @map("amount_usd") @db.Decimal(12, 2) - minPriceUsd Decimal? @map("min_price_usd") @db.Decimal(12, 2) + sourceAmount Decimal @map("amount_usd") @db.Decimal(12, 2) + minSourcePrice Decimal? @map("min_price_usd") @db.Decimal(12, 2) etaDays Int? @map("eta_days") 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") notes String? 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) tariffMatchSummary String? @map("tariff_match_summary") tariffSnapshot String? @map("tariff_snapshot") - totalAmount Decimal @default(0) @map("total_amount") @db.Decimal(12, 2) - currency String @default("USD") @db.VarChar(10) + sourceAmount Decimal? @map("source_amount") @db.Decimal(12, 2) + 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") notes String? createdAt DateTime @default(now()) @map("created_at") @@ -116,8 +126,18 @@ model Order { createdByUserId String? @map("created_by_user_id") @db.VarChar(255) name String @db.VarChar(255) status String @default("draft") @db.VarChar(30) - totalAmount Decimal @default(0) @map("total_amount") @db.Decimal(12, 2) - currency String @default("USD") @db.VarChar(10) + sourceAmount Decimal? @map("source_amount") @db.Decimal(12, 2) + 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) sourceLocationName String? @map("source_location_name") @db.VarChar(255) sourceCountryCode String? @map("source_country_code") @db.VarChar(10) diff --git a/src/demo-workspace.ts b/src/demo-workspace.ts index d10461b..6f59df2 100644 --- a/src/demo-workspace.ts +++ b/src/demo-workspace.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto' import { Prisma } from '@prisma/client' import { prisma } from './db.js' +import { lockCurrencyPrice, resolveKycPricingContext } from './pricing-context.js' interface DemoWorkspaceAccess { teamUuid: string @@ -111,12 +112,22 @@ function stableUuid(teamUuid: string, entity: string, index: number): string { } export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Promise { - const [quotationCount, orderCount] = await Promise.all([ + const [quotationCount, orderCount, demoQuotationPricingCount, demoOrderPricingCount] = await Promise.all([ prisma.quotation.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 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()) { const createsOrder = index < 2 @@ -132,11 +143,11 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom status: 'active', sourceCountryCode: route.sourceCountryCode, destinationCountryCode: route.destinationCountryCode, - amountUsd: new Prisma.Decimal(route.amountUsd), - minPriceUsd: new Prisma.Decimal(route.amountUsd), + sourceAmount: new Prisma.Decimal(route.amountUsd), + minSourcePrice: new Prisma.Decimal(route.amountUsd), etaDays: route.etaDays, priority: 50 + index, - currency: 'USD', + sourceCurrency: 'USD', notes: demoNote, }, update: { @@ -150,16 +161,25 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom tareTypeCode: null, sourceCountryCode: route.sourceCountryCode, destinationCountryCode: route.destinationCountryCode, - amountUsd: new Prisma.Decimal(route.amountUsd), - minPriceUsd: new Prisma.Decimal(route.amountUsd), + sourceAmount: new Prisma.Decimal(route.amountUsd), + minSourcePrice: new Prisma.Decimal(route.amountUsd), etaDays: route.etaDays, priority: 50 + index, - currency: 'USD', + sourceCurrency: 'USD', 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 quotation = await prisma.quotation.upsert({ @@ -188,8 +208,18 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom routeDistanceKm: route.routeDistanceKm, selectedTariffId: tariff.id, tariffMatchSummary: `Demo tariff: ${route.title}`, - totalAmount: new Prisma.Decimal(route.amountUsd), - currency: 'USD', + sourceAmount: new Prisma.Decimal(price.sourceAmount), + 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, notes: demoNote, }, @@ -197,14 +227,24 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom teamUuid: access.teamUuid, createdByUserId: access.userId, selectedTariffId: tariff.id, - totalAmount: new Prisma.Decimal(route.amountUsd), - currency: 'USD', + sourceAmount: new Prisma.Decimal(price.sourceAmount), + 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, notes: demoNote, }, }) - if (!needsOrders || !createsOrder) continue + if ((!needsOrders && !needsPricingBackfill) || !createsOrder) continue await prisma.order.upsert({ where: { uuid: stableUuid(access.teamUuid, 'demo-order', index) }, create: { @@ -214,8 +254,18 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom createdByUserId: access.userId, name: route.title, status: index === 0 ? 'in_transit' : 'processing', - totalAmount: new Prisma.Decimal(route.amountUsd), - currency: 'USD', + sourceAmount: new Prisma.Decimal(price.sourceAmount), + 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, sourceCountryCode: route.sourceCountryCode, sourceLatitude: route.sourceLatitude, @@ -231,8 +281,18 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom teamUuid: access.teamUuid, createdByUserId: access.userId, status: index === 0 ? 'in_transit' : 'processing', - totalAmount: new Prisma.Decimal(route.amountUsd), - currency: 'USD', + sourceAmount: new Prisma.Decimal(price.sourceAmount), + 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, }, }) diff --git a/src/pricing-context.ts b/src/pricing-context.ts new file mode 100644 index 0000000..bea42cc --- /dev/null +++ b/src/pricing-context.ts @@ -0,0 +1,110 @@ +type GraphqlEnvelope = { + 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( + endpoint: string, + query: string, + variables: Record, +): Promise { + 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 + 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 { + 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 { + 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 +} diff --git a/src/schemas/team.ts b/src/schemas/team.ts index b1bd7aa..45232af 100644 --- a/src/schemas/team.ts +++ b/src/schemas/team.ts @@ -9,6 +9,12 @@ import { import { requireScopes, type AuthContext } from '../auth.js' import { prisma } from '../db.js' import { ensureDemoWorkspaceData } from '../demo-workspace.js' +import { + lockCurrencyPrice, + resolveKycPricingContext, + type KycPricingContext, + type LockedCurrencyPrice, +} from '../pricing-context.js' const quotationInclude = { selectedTariff: true, @@ -30,7 +36,7 @@ interface TariffMatchResult { tariff: PrismaTariffReference score: number reasons: string[] - projectedAmount: number + projectedSourceAmount: number projectedEtaDays: number | null } @@ -102,8 +108,20 @@ export const teamTypeDefs = `#graphql teamUuid: String userId: String status: String - totalAmount: Float - currency: String + sourceAmount: Float + 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 sourceLocationUuid: String sourceLocationName: String @@ -139,11 +157,14 @@ export const teamTypeDefs = `#graphql maxVolumeCbm: Float minDistanceKm: Int maxDistanceKm: Int - amountUsd: Float! - minPriceUsd: Float + sourceAmount: Float! + sourceCurrency: String! + minSourcePrice: Float + amountUsd: Float! @deprecated(reason: "Use sourceAmount") + minPriceUsd: Float @deprecated(reason: "Use minSourcePrice") etaDays: Int priority: Int! - currency: String! + currency: String! @deprecated(reason: "Use sourceCurrency") dmnExpression: String notes: String createdByUserId: String @@ -191,8 +212,20 @@ export const teamTypeDefs = `#graphql selectedTariff: TariffReference tariffMatchSummary: String tariffSnapshot: String - totalAmount: Float! - currency: String! + sourceAmount: Float + 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 notes: String createdAt: String! @@ -204,7 +237,12 @@ export const teamTypeDefs = `#graphql tariffReference: TariffReference! score: Int! reasons: [String!]! - projectedAmount: Float! + projectedSourceAmount: Float! + sourceCurrency: String! + projectedClientAmount: Float! + clientCurrency: String! + exchangeRate: Float! + projectedAmount: Float! @deprecated(reason: "Use projectedClientAmount") projectedEtaDays: Int isSelected: Boolean! } @@ -226,7 +264,10 @@ export const teamTypeDefs = `#graphql maxVolumeCbm: Float minDistanceKm: Int maxDistanceKm: Int - amountUsd: Float! + sourceAmount: Float + sourceCurrency: String + minSourcePrice: Float + amountUsd: Float minPriceUsd: Float etaDays: Int priority: Int @@ -252,6 +293,9 @@ export const teamTypeDefs = `#graphql maxVolumeCbm: Float minDistanceKm: Int maxDistanceKm: Int + sourceAmount: Float + sourceCurrency: String + minSourcePrice: Float amountUsd: Float minPriceUsd: Float etaDays: Int @@ -473,11 +517,14 @@ function mapTariffReference(item: PrismaTariffReference) { maxVolumeCbm: numberFromDecimal(item.maxVolumeCbm), minDistanceKm: item.minDistanceKm, maxDistanceKm: item.maxDistanceKm, - amountUsd: Number(item.amountUsd), - minPriceUsd: numberFromDecimal(item.minPriceUsd), + sourceAmount: Number(item.sourceAmount), + sourceCurrency: item.sourceCurrency, + minSourcePrice: numberFromDecimal(item.minSourcePrice), + amountUsd: Number(item.sourceAmount), + minPriceUsd: numberFromDecimal(item.minSourcePrice), etaDays: item.etaDays, priority: item.priority, - currency: item.currency, + currency: item.sourceCurrency, dmnExpression: item.dmnExpression, notes: item.notes, createdByUserId: item.createdByUserId, @@ -529,8 +576,20 @@ function mapQuotation(item: QuotationWithRelations) { selectedTariff: item.selectedTariff ? mapTariffReference(item.selectedTariff) : null, tariffMatchSummary: item.tariffMatchSummary, tariffSnapshot: item.tariffSnapshot, - totalAmount: Number(item.totalAmount), - currency: item.currency, + sourceAmount: numberFromDecimal(item.sourceAmount), + 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, notes: item.notes, createdAt: item.createdAt.toISOString(), @@ -550,8 +609,20 @@ function mapLocalOrder(item: OrderWithRelations) { teamUuid: item.teamUuid, userId: item.createdByUserId, status: item.status, - totalAmount: Number(item.totalAmount), - currency: item.currency, + sourceAmount: numberFromDecimal(item.sourceAmount), + 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, sourceLocationUuid: item.sourceLocationUuid, sourceLocationName: item.sourceLocationName, @@ -615,7 +686,24 @@ function requiredString(value: unknown, label: string): string { return next } +function compatibleInput( + input: Record, + 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 { + const sourceAmount = normalizeDecimal(compatibleInput(input, 'sourceAmount', 'amountUsd')) + if (sourceAmount === null) { + throw new GraphQLError('sourceAmount is required') + } return { teamUuid: ctx.teamUuid, createdByUserId: ctx.userId, @@ -635,11 +723,11 @@ function buildCreateTariffData(input: CreateTariffReferenceInput, ctx: { teamUui maxVolumeCbm: normalizeDecimal(input.maxVolumeCbm), minDistanceKm: normalizeInteger(input.minDistanceKm), maxDistanceKm: normalizeInteger(input.maxDistanceKm), - amountUsd: normalizeDecimal(input.amountUsd) ?? new Prisma.Decimal(0), - minPriceUsd: normalizeDecimal(input.minPriceUsd), + sourceAmount, + minSourcePrice: normalizeDecimal(compatibleInput(input, 'minSourcePrice', 'minPriceUsd')), etaDays: normalizeInteger(input.etaDays), priority: normalizeInteger(input.priority) ?? 100, - currency: normalizeUpper(input.currency) ?? 'USD', + sourceCurrency: normalizeUpper(compatibleInput(input, 'sourceCurrency', 'currency')) ?? 'USD', dmnExpression: normalizeString(input.dmnExpression), 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, 'minDistanceKm')) data.minDistanceKm = normalizeInteger(input.minDistanceKm) if (hasOwn(input, 'maxDistanceKm')) data.maxDistanceKm = normalizeInteger(input.maxDistanceKm) - if (hasOwn(input, 'amountUsd')) data.amountUsd = normalizeRequiredUpdateDecimal(input.amountUsd) - if (hasOwn(input, 'minPriceUsd')) data.minPriceUsd = normalizeDecimal(input.minPriceUsd) + if (hasOwn(input, 'sourceAmount') || hasOwn(input, 'amountUsd')) { + 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, '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, 'notes')) data.notes = normalizeString(input.notes) @@ -798,12 +898,15 @@ function computeTariffMatches( const rangeScoreParts = rangeChecks.filter((item): item is number => item != null) 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({ tariff, score, reasons, - projectedAmount, + projectedSourceAmount, projectedEtaDays: tariff.etaDays, }) } @@ -869,6 +972,63 @@ function actorLabel(ctx: { userId: string; teamUuid: string }): string { 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 }) { const quotation = await prisma.quotation.findFirst({ where: { @@ -889,6 +1049,13 @@ async function refreshQuotationSelection(quotationUuid: string, teamUuid: string const matches = computeTariffMatches(quotation, tariffs) const bestMatch = matches[0] ?? null + const locked = bestMatch + ? await lockTariffPrice( + bestMatch.tariff, + bestMatch.projectedSourceAmount, + quotationPricingUserId(quotation), + ) + : null const tariffMatchSummary = bestMatch ? `Matched ${bestMatch.tariff.name}; ${bestMatch.reasons.join(', ')}` @@ -903,9 +1070,12 @@ async function refreshQuotationSelection(quotationUuid: string, teamUuid: string tariffReferenceUuid: item.tariff.uuid, score: item.score, reasons: item.reasons, - projectedAmount: item.projectedAmount, + projectedSourceAmount: item.projectedSourceAmount, + sourceCurrency: item.tariff.sourceCurrency, projectedEtaDays: item.projectedEtaDays, })), + lockedPrice: locked?.price ?? null, + pricingKycProfileUuid: locked?.kyc.profileUuid ?? null, }) const updated = await prisma.quotation.update({ @@ -914,8 +1084,7 @@ async function refreshQuotationSelection(quotationUuid: string, teamUuid: string }, data: { selectedTariffId: bestMatch?.tariff.id ?? null, - totalAmount: new Prisma.Decimal(bestMatch?.projectedAmount ?? 0), - currency: bestMatch?.tariff.currency ?? quotation.currency, + ...lockedQuotationPriceData(locked, quotation.clientCurrency), etaDays: bestMatch?.projectedEtaDays ?? null, tariffMatchSummary, tariffSnapshot, @@ -1062,14 +1231,29 @@ export const teamResolvers = { }) const matches = computeTariffMatches(quotation, tariffs) - return matches.map(item => ({ - tariffReference: mapTariffReference(item.tariff), - score: item.score, - reasons: item.reasons, - projectedAmount: item.projectedAmount, - projectedEtaDays: item.projectedEtaDays, - isSelected: quotation.selectedTariff?.uuid === item.tariff.uuid, - })) + 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), + score: item.score, + reasons: item.reasons, + projectedSourceAmount: item.projectedSourceAmount, + sourceCurrency: item.tariff.sourceCurrency, + projectedClientAmount: price.clientAmount, + clientCurrency: price.clientCurrency, + exchangeRate: price.exchangeRate, + projectedAmount: price.clientAmount, + projectedEtaDays: item.projectedEtaDays, + isSelected: quotation.selectedTariff?.uuid === item.tariff.uuid, + } + }), + ) }, }, @@ -1166,15 +1350,22 @@ export const teamResolvers = { const actor = { userId: access.userId, label: actorLabel(teamAccess) } 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({ where: { id: quotation.id, }, data: { selectedTariffId: tariff.id, - totalAmount: new Prisma.Decimal(projectedAmount), - currency: tariff.currency, + ...lockedQuotationPriceData(locked, quotation.clientCurrency), etaDays: tariff.etaDays, tariffMatchSummary: `Manually selected ${tariff.name}`, tariffSnapshot: JSON.stringify({ @@ -1182,6 +1373,8 @@ export const teamResolvers = { selectedTariffUuid: tariff.uuid, selectedAt: new Date().toISOString(), selectionMode: 'manual', + lockedPrice: locked.price, + pricingKycProfileUuid: locked.kyc.profileUuid, }), status: quotation.status === 'converted' ? quotation.status : 'priced', }, @@ -1216,16 +1409,36 @@ export const teamResolvers = { if (!quotation.selectedTariff) { 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({ data: { teamUuid: quotation.teamUuid, quotationId: quotation.id, - createdByUserId: access.userId, + createdByUserId: quotation.createdByUserId ?? access.userId, name: quotation.title.startsWith('Order') ? quotation.title : `Order for ${quotation.title}`, status: 'created', - totalAmount: quotation.totalAmount, - currency: quotation.currency, + sourceAmount: quotation.sourceAmount, + 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, sourceLocationName: quotation.sourceLocationName, sourceCountryCode: quotation.sourceCountryCode,