This commit is contained in:
+1
-1
Submodule graphql-contracts updated: 88e1aa58bf...48cd334d24
@@ -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
@@ -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
@@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 { 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))
|
||||||
tariffReference: mapTariffReference(item.tariff),
|
return Promise.all(
|
||||||
score: item.score,
|
matches.map(async item => {
|
||||||
reasons: item.reasons,
|
const price = await lockCurrencyPrice({
|
||||||
projectedAmount: item.projectedAmount,
|
sourceAmount: item.projectedSourceAmount,
|
||||||
projectedEtaDays: item.projectedEtaDays,
|
sourceCurrency: item.tariff.sourceCurrency,
|
||||||
isSelected: quotation.selectedTariff?.uuid === item.tariff.uuid,
|
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 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user