This commit is contained in:
+77
-17
@@ -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<void> {
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+255
-42
@@ -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<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 {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user