Persist daily currency rate snapshots
Build and deploy Docker image / build (push) Successful in 58s

This commit is contained in:
Ruslan Bakiev
2026-08-24 18:09:10 +07:00
parent d223845310
commit bd06ee04c0
10 changed files with 582 additions and 8 deletions
+18
View File
@@ -0,0 +1,18 @@
meta {
name: Historical USD rates
type: http
seq: 2
}
get {
url: https://v6.exchangerate-api.com/v6/{{exchangeRateApiKey}}/history/USD/{{year}}/{{month}}/{{day}}
body: none
auth: none
}
assert {
res.status: eq 200
res.body.result: eq success
res.body.base_code: eq USD
res.body.conversion_rates.USD: eq 1
}
@@ -0,0 +1,116 @@
-- CreateTable
CREATE TABLE "exchange_currency_rate_snapshots" (
"id" SERIAL NOT NULL,
"uuid" TEXT NOT NULL,
"base_currency" VARCHAR(3) NOT NULL,
"effective_date" DATE NOT NULL,
"quoted_at" TIMESTAMP(3) NOT NULL,
"next_update_at" TIMESTAMP(3),
"provider" VARCHAR(255) NOT NULL,
"documentation" VARCHAR(500) NOT NULL,
"terms_of_use" VARCHAR(500) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "exchange_currency_rate_snapshots_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "exchange_currency_rates" (
"id" SERIAL NOT NULL,
"snapshot_id" INTEGER NOT NULL,
"quote_currency" VARCHAR(3) NOT NULL,
"rate" DECIMAL(24,10) NOT NULL,
CONSTRAINT "exchange_currency_rates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "exchange_pricing_currency_policies" (
"country_code" VARCHAR(2) NOT NULL,
"currency_code" VARCHAR(3) NOT NULL,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "exchange_pricing_currency_policies_pkey" PRIMARY KEY ("country_code")
);
-- CreateIndex
CREATE UNIQUE INDEX "exchange_currency_rate_snapshots_uuid_key" ON "exchange_currency_rate_snapshots"("uuid");
-- CreateIndex
CREATE INDEX "exchange_currency_rate_snapshots_effective_date_idx" ON "exchange_currency_rate_snapshots"("effective_date");
-- CreateIndex
CREATE UNIQUE INDEX "exchange_currency_rate_snapshots_base_currency_effective_da_key" ON "exchange_currency_rate_snapshots"("base_currency", "effective_date");
-- CreateIndex
CREATE INDEX "exchange_currency_rates_quote_currency_idx" ON "exchange_currency_rates"("quote_currency");
-- CreateIndex
CREATE UNIQUE INDEX "exchange_currency_rates_snapshot_id_quote_currency_key" ON "exchange_currency_rates"("snapshot_id", "quote_currency");
-- CreateIndex
CREATE INDEX "exchange_pricing_currency_policies_currency_code_idx" ON "exchange_pricing_currency_policies"("currency_code");
-- AddForeignKey
ALTER TABLE "exchange_currency_rates" ADD CONSTRAINT "exchange_currency_rates_snapshot_id_fkey" FOREIGN KEY ("snapshot_id") REFERENCES "exchange_currency_rate_snapshots"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Seed pricing currencies for countries already present in Optovia data.
INSERT INTO "exchange_pricing_currency_policies" ("country_code", "currency_code", "updated_at") VALUES
('AE', 'AED', CURRENT_TIMESTAMP),
('AO', 'AOA', CURRENT_TIMESTAMP),
('BF', 'XOF', CURRENT_TIMESTAMP),
('BI', 'BIF', CURRENT_TIMESTAMP),
('BJ', 'XOF', CURRENT_TIMESTAMP),
('BW', 'BWP', CURRENT_TIMESTAMP),
('CD', 'CDF', CURRENT_TIMESTAMP),
('CF', 'XAF', CURRENT_TIMESTAMP),
('CG', 'XAF', CURRENT_TIMESTAMP),
('CI', 'XOF', CURRENT_TIMESTAMP),
('CM', 'XAF', CURRENT_TIMESTAMP),
('CN', 'CNY', CURRENT_TIMESTAMP),
('CV', 'CVE', CURRENT_TIMESTAMP),
('DJ', 'DJF', CURRENT_TIMESTAMP),
('DZ', 'DZD', CURRENT_TIMESTAMP),
('EG', 'EGP', CURRENT_TIMESTAMP),
('ET', 'ETB', CURRENT_TIMESTAMP),
('GA', 'XAF', CURRENT_TIMESTAMP),
('GH', 'GHS', CURRENT_TIMESTAMP),
('GM', 'GMD', CURRENT_TIMESTAMP),
('GN', 'GNF', CURRENT_TIMESTAMP),
('GQ', 'XAF', CURRENT_TIMESTAMP),
('GW', 'XOF', CURRENT_TIMESTAMP),
('KE', 'KES', CURRENT_TIMESTAMP),
('KM', 'KMF', CURRENT_TIMESTAMP),
('KZ', 'KZT', CURRENT_TIMESTAMP),
('LR', 'LRD', CURRENT_TIMESTAMP),
('LS', 'LSL', CURRENT_TIMESTAMP),
('LY', 'LYD', CURRENT_TIMESTAMP),
('MA', 'MAD', CURRENT_TIMESTAMP),
('MG', 'MGA', CURRENT_TIMESTAMP),
('ML', 'XOF', CURRENT_TIMESTAMP),
('MR', 'MRU', CURRENT_TIMESTAMP),
('MU', 'MUR', CURRENT_TIMESTAMP),
('MW', 'MWK', CURRENT_TIMESTAMP),
('MZ', 'MZN', CURRENT_TIMESTAMP),
('NA', 'NAD', CURRENT_TIMESTAMP),
('NE', 'XOF', CURRENT_TIMESTAMP),
('NG', 'NGN', CURRENT_TIMESTAMP),
('PL', 'PLN', CURRENT_TIMESTAMP),
('RU', 'RUB', CURRENT_TIMESTAMP),
('RW', 'RWF', CURRENT_TIMESTAMP),
('SC', 'SCR', CURRENT_TIMESTAMP),
('SD', 'SDG', CURRENT_TIMESTAMP),
('SL', 'SLE', CURRENT_TIMESTAMP),
('SN', 'XOF', CURRENT_TIMESTAMP),
('SO', 'SOS', CURRENT_TIMESTAMP),
('SS', 'SSP', CURRENT_TIMESTAMP),
('ST', 'STN', CURRENT_TIMESTAMP),
('SZ', 'SZL', CURRENT_TIMESTAMP),
('TD', 'XAF', CURRENT_TIMESTAMP),
('TG', 'XOF', CURRENT_TIMESTAMP),
('TN', 'TND', CURRENT_TIMESTAMP),
('TZ', 'TZS', CURRENT_TIMESTAMP),
('UG', 'UGX', CURRENT_TIMESTAMP),
('ZA', 'ZAR', CURRENT_TIMESTAMP),
('ZM', 'ZMW', CURRENT_TIMESTAMP),
('ZW', 'ZWG', CURRENT_TIMESTAMP);
+39
View File
@@ -69,3 +69,42 @@ model Product {
@@index([categoryName, isActive])
@@map("exchange_products")
}
model CurrencyRateSnapshot {
id Int @id @default(autoincrement())
uuid String @unique @default(uuid())
baseCurrency String @map("base_currency") @db.VarChar(3)
effectiveDate DateTime @map("effective_date") @db.Date
quotedAt DateTime @map("quoted_at")
nextUpdateAt DateTime? @map("next_update_at")
provider String @db.VarChar(255)
documentation String @db.VarChar(500)
termsOfUse String @map("terms_of_use") @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at")
rates CurrencyRate[]
@@unique([baseCurrency, effectiveDate])
@@index([effectiveDate])
@@map("exchange_currency_rate_snapshots")
}
model CurrencyRate {
id Int @id @default(autoincrement())
snapshotId Int @map("snapshot_id")
snapshot CurrencyRateSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Cascade)
quoteCurrency String @map("quote_currency") @db.VarChar(3)
rate Decimal @db.Decimal(24, 10)
@@unique([snapshotId, quoteCurrency])
@@index([quoteCurrency])
@@map("exchange_currency_rates")
}
model PricingCurrencyPolicy {
countryCode String @id @map("country_code") @db.VarChar(2)
currencyCode String @map("currency_code") @db.VarChar(3)
updatedAt DateTime @updatedAt @map("updated_at")
@@index([currencyCode])
@@map("exchange_pricing_currency_policies")
}
+128
View File
@@ -0,0 +1,128 @@
import { Prisma } from '@prisma/client'
import { prisma } from './db.js'
import {
currencyCode,
currencyRates,
historicalCurrencyRates,
type CurrencyRatesSnapshot,
} from './currency-rates.js'
const snapshotInclude = {
rates: true,
} satisfies Prisma.CurrencyRateSnapshotInclude
type StoredSnapshot = Prisma.CurrencyRateSnapshotGetPayload<{
include: typeof snapshotInclude
}>
export type StoredCurrencyRatesSnapshot = CurrencyRatesSnapshot & {
uuid: string
effectiveDate: Date
}
function utcDate(value: Date): Date {
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()))
}
function snapshotFromStored(item: StoredSnapshot): StoredCurrencyRatesSnapshot {
return {
uuid: item.uuid,
baseCurrency: 'USD',
effectiveDate: item.effectiveDate,
rates: Object.fromEntries(
item.rates.map(rate => [rate.quoteCurrency, Number(rate.rate)]),
),
updatedAt: item.quotedAt,
nextUpdateAt: item.nextUpdateAt ?? item.quotedAt,
provider: item.provider,
documentation: item.documentation,
termsOfUse: item.termsOfUse,
attributionUrl: 'https://www.exchangerate-api.com',
}
}
function rateRows(snapshot: CurrencyRatesSnapshot) {
return Object.entries(snapshot.rates).map(([rawCurrency, rate]) => {
const quoteCurrency = currencyCode(rawCurrency)
if (!Number.isFinite(rate) || rate <= 0) {
throw new Error(`Invalid ${quoteCurrency} exchange rate: ${rate}`)
}
return {
quoteCurrency,
rate: new Prisma.Decimal(rate),
}
})
}
async function findStored(effectiveDate: Date): Promise<StoredSnapshot | null> {
return prisma.currencyRateSnapshot.findUnique({
where: {
baseCurrency_effectiveDate: {
baseCurrency: 'USD',
effectiveDate,
},
},
include: snapshotInclude,
})
}
export async function persistCurrencyRates(
snapshot: CurrencyRatesSnapshot,
): Promise<StoredCurrencyRatesSnapshot> {
const effectiveDate = utcDate(snapshot.updatedAt)
const rows = rateRows(snapshot)
const stored = await prisma.$transaction(async transaction => {
const item = await transaction.currencyRateSnapshot.upsert({
where: {
baseCurrency_effectiveDate: {
baseCurrency: snapshot.baseCurrency,
effectiveDate,
},
},
create: {
baseCurrency: snapshot.baseCurrency,
effectiveDate,
quotedAt: snapshot.updatedAt,
nextUpdateAt: snapshot.nextUpdateAt,
provider: snapshot.provider,
documentation: snapshot.documentation,
termsOfUse: snapshot.termsOfUse,
},
update: {
quotedAt: snapshot.updatedAt,
nextUpdateAt: snapshot.nextUpdateAt,
provider: snapshot.provider,
documentation: snapshot.documentation,
termsOfUse: snapshot.termsOfUse,
},
})
await transaction.currencyRate.deleteMany({ where: { snapshotId: item.id } })
await transaction.currencyRate.createMany({
data: rows.map(row => ({ snapshotId: item.id, ...row })),
})
return transaction.currencyRateSnapshot.findUniqueOrThrow({
where: { id: item.id },
include: snapshotInclude,
})
})
return snapshotFromStored(stored)
}
export async function latestCurrencyRatesSnapshot(): Promise<StoredCurrencyRatesSnapshot> {
const latest = await currencyRates()
const stored = await findStored(utcDate(latest.updatedAt))
return stored === null ? persistCurrencyRates(latest) : snapshotFromStored(stored)
}
export async function currencyRatesSnapshotForDate(
requestedDate: Date,
): Promise<StoredCurrencyRatesSnapshot> {
const effectiveDate = utcDate(requestedDate)
if (effectiveDate.getTime() > utcDate(new Date()).getTime()) {
throw new Error('Currency rate date cannot be in the future')
}
const stored = await findStored(effectiveDate)
if (stored !== null) return snapshotFromStored(stored)
return persistCurrencyRates(await historicalCurrencyRates(effectiveDate))
}
+22 -1
View File
@@ -1,7 +1,12 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { convertMoney, displayCurrency, parseCurrencyRates } from './currency-rates.js'
import {
convertMoney,
displayCurrency,
parseCurrencyRates,
parseHistoricalCurrencyRates,
} from './currency-rates.js'
const payload = {
result: 'success',
@@ -40,3 +45,19 @@ test('rejects incomplete provider responses', () => {
/no valid RUB rate/,
)
})
test('parses a historical daily rate snapshot', () => {
const rates = parseHistoricalCurrencyRates({
result: 'success',
documentation: 'https://www.exchangerate-api.com/docs',
terms_of_use: 'https://www.exchangerate-api.com/terms',
year: 2025,
month: 8,
day: 24,
base_code: 'USD',
conversion_rates: payload.rates,
})
assert.equal(rates.updatedAt.toISOString(), '2025-08-24T00:00:00.000Z')
assert.equal(convertMoney(100, 'USD', 'RUB', rates), 8_280.2865)
})
+82 -3
View File
@@ -14,6 +14,18 @@ type ExchangeRateApiResponse = {
'error-type'?: string
}
type ExchangeRateApiHistoricalResponse = {
result?: string
documentation?: string
terms_of_use?: string
year?: number
month?: number
day?: number
base_code?: string
conversion_rates?: Record<string, number>
'error-type'?: string
}
export type CurrencyRatesSnapshot = {
baseCurrency: 'USD'
rates: Record<string, number>
@@ -26,6 +38,7 @@ export type CurrencyRatesSnapshot = {
}
const exchangeRateApiUrl = 'https://open.er-api.com/v6/latest/USD'
const exchangeRateApiProvider = 'https://www.exchangerate-api.com'
const displayCurrencyCodes = new Set<string>(DISPLAY_CURRENCIES)
let cachedRates: CurrencyRatesSnapshot | null = null
@@ -38,6 +51,14 @@ export function displayCurrency(value: unknown): DisplayCurrency {
return normalized as DisplayCurrency
}
export function currencyCode(value: unknown): string {
const normalized = String(value ?? '').trim().toUpperCase()
if (!/^[A-Z]{3}$/.test(normalized)) {
throw new Error(`Invalid ISO 4217 currency code: ${normalized || 'empty'}`)
}
return normalized
}
export function parseCurrencyRates(payload: ExchangeRateApiResponse): CurrencyRatesSnapshot {
if (payload.result !== 'success') {
throw new Error(`ExchangeRate-API error: ${payload['error-type'] ?? payload.result ?? 'invalid response'}`)
@@ -65,10 +86,63 @@ export function parseCurrencyRates(payload: ExchangeRateApiResponse): CurrencyRa
provider: payload.provider,
documentation: payload.documentation,
termsOfUse: payload.terms_of_use,
attributionUrl: 'https://www.exchangerate-api.com',
attributionUrl: exchangeRateApiProvider,
}
}
export function parseHistoricalCurrencyRates(
payload: ExchangeRateApiHistoricalResponse,
): CurrencyRatesSnapshot {
if (payload.result !== 'success') {
throw new Error(
`ExchangeRate-API historical error: ${payload['error-type'] ?? payload.result ?? 'invalid response'}`,
)
}
if (payload.base_code !== 'USD') {
throw new Error(
`ExchangeRate-API historical response returned unexpected base currency: ${payload.base_code ?? 'empty'}`,
)
}
if (!payload.year || !payload.month || !payload.day || !payload.conversion_rates) {
throw new Error('ExchangeRate-API historical response is missing date or rates')
}
if (!payload.documentation || !payload.terms_of_use) {
throw new Error('ExchangeRate-API historical response is missing attribution metadata')
}
const quotedAt = new Date(Date.UTC(payload.year, payload.month - 1, payload.day))
return {
baseCurrency: 'USD',
rates: payload.conversion_rates,
updatedAt: quotedAt,
nextUpdateAt: quotedAt,
provider: exchangeRateApiProvider,
documentation: payload.documentation,
termsOfUse: payload.terms_of_use,
attributionUrl: exchangeRateApiProvider,
}
}
export async function historicalCurrencyRates(rateDate: Date): Promise<CurrencyRatesSnapshot> {
const apiKey = process.env.EXCHANGE_RATE_API_KEY?.trim()
if (!apiKey) {
throw new Error(
'Historical currency rate is not stored and EXCHANGE_RATE_API_KEY is required for backfill',
)
}
const year = rateDate.getUTCFullYear()
const month = rateDate.getUTCMonth() + 1
const day = rateDate.getUTCDate()
const response = await fetch(
`https://v6.exchangerate-api.com/v6/${apiKey}/history/USD/${year}/${month}/${day}`,
)
if (!response.ok) {
throw new Error(`ExchangeRate-API historical request failed: ${response.status}`)
}
return parseHistoricalCurrencyRates(
(await response.json()) as ExchangeRateApiHistoricalResponse,
)
}
export async function currencyRates(): Promise<CurrencyRatesSnapshot> {
if (cachedRates && Date.now() < cachedRates.nextUpdateAt.getTime()) {
return cachedRates
@@ -84,7 +158,7 @@ export async function currencyRates(): Promise<CurrencyRatesSnapshot> {
export function convertMoney(
amount: number,
sourceCurrency: string,
targetCurrency: DisplayCurrency,
targetCurrencyValue: string,
snapshot: CurrencyRatesSnapshot,
) {
if (!Number.isFinite(amount)) {
@@ -92,23 +166,28 @@ export function convertMoney(
}
const normalizedSource = sourceCurrency.trim().toUpperCase()
const sourceRate = snapshot.rates[normalizedSource]
const targetCurrency = currencyCode(targetCurrencyValue)
const targetRate = snapshot.rates[targetCurrency]
if (typeof sourceRate !== 'number' || !Number.isFinite(sourceRate) || sourceRate <= 0) {
throw new Error(`No exchange rate for source currency: ${normalizedSource || 'empty'}`)
}
if (typeof targetRate !== 'number' || !Number.isFinite(targetRate) || targetRate <= 0) {
throw new Error(`No exchange rate for target currency: ${targetCurrency}`)
}
return amount / sourceRate * targetRate
}
export async function convertPrices<T extends { pricePerUnit: unknown; currency: string }>(
rows: T[],
targetCurrencyValue: unknown,
providedSnapshot?: CurrencyRatesSnapshot,
) {
const targetCurrency = displayCurrency(targetCurrencyValue)
const needsConversion = rows.some(row => row.currency.trim().toUpperCase() !== targetCurrency)
if (!needsConversion) {
return rows.map(row => ({ ...row, pricePerUnit: Number(row.pricePerUnit), currency: targetCurrency }))
}
const snapshot = await currencyRates()
const snapshot = providedSnapshot ?? await currencyRates()
return rows.map(row => ({
...row,
pricePerUnit: convertMoney(Number(row.pricePerUnit), row.currency, targetCurrency, snapshot),
+13
View File
@@ -3,6 +3,7 @@ import cors from "@fastify/cors";
import mercurius from "mercurius";
import * as Sentry from "@sentry/node";
import { resolvers, typeDefs } from "./schema.js";
import { m2mResolvers, m2mTypeDefs } from "./m2m-schema.js";
const PORT = Number.parseInt(process.env.PORT || "8000", 10);
const SENTRY_DSN = process.env.SENTRY_DSN || "";
@@ -30,9 +31,21 @@ await app.register(mercurius, {
path: "/graphql",
graphiql: true,
});
await app.register(
async (route) => {
await route.register(mercurius, {
schema: m2mTypeDefs,
resolvers: m2mResolvers,
path: "/",
graphiql: false,
});
},
{ prefix: "/graphql/m2m" },
);
app.get("/health", async () => ({ status: "ok", service: "exchange" }));
await app.listen({ port: PORT, host: "0.0.0.0" });
console.log(`Exchange server ready on port ${PORT}`);
console.log(" /graphql - suppliers, products, quotes");
console.log(" /graphql/m2m - pricing currency and locked conversions");
+151
View File
@@ -0,0 +1,151 @@
import { GraphQLError } from 'graphql'
import {
currencyRatesSnapshotForDate,
latestCurrencyRatesSnapshot,
} from './currency-rate-store.js'
import { convertMoney, currencyCode } from './currency-rates.js'
import { prisma } from './db.js'
type LockCurrencyPriceInput = {
sourceAmount: number
sourceCurrency: string
pricingCountryCode: string
rateDate?: string | null
}
export const m2mTypeDefs = `#graphql
input LockCurrencyPriceInput {
sourceAmount: Float!
sourceCurrency: String!
pricingCountryCode: String!
rateDate: String
}
type LockedCurrencyPrice {
sourceAmount: Float!
sourceCurrency: String!
clientAmount: Float!
clientCurrency: String!
exchangeRate: Float!
rateSnapshotUuid: String
rateEffectiveDate: String!
rateQuotedAt: String!
rateLockedAt: String!
provider: String!
attributionUrl: String
pricingCountryCode: String!
}
type PricingCurrencyPolicy {
countryCode: String!
currencyCode: String!
}
type Query {
pricingCurrencyForCountry(countryCode: String!): PricingCurrencyPolicy
}
type Mutation {
lockCurrencyPrice(input: LockCurrencyPriceInput!): LockedCurrencyPrice!
}
`
function countryCode(value: unknown): string {
const normalized = String(value ?? '').trim().toUpperCase()
if (!/^[A-Z]{2}$/.test(normalized)) {
throw new GraphQLError(`Invalid ISO 3166-1 country code: ${normalized || 'empty'}`)
}
return normalized
}
function historicalDate(value: string): Date {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new GraphQLError('rateDate must use YYYY-MM-DD format')
}
const result = new Date(`${value}T00:00:00.000Z`)
if (Number.isNaN(result.getTime()) || result.toISOString().slice(0, 10) !== value) {
throw new GraphQLError('rateDate is not a valid calendar date')
}
return result
}
async function pricingCurrencyForCountryValue(rawCountryCode: unknown) {
const normalizedCountryCode = countryCode(rawCountryCode)
const policy = await prisma.pricingCurrencyPolicy.findUnique({
where: { countryCode: normalizedCountryCode },
})
if (policy === null) return null
return {
countryCode: policy.countryCode,
currencyCode: policy.currencyCode,
}
}
async function lockCurrencyPrice(
_: unknown,
args: { input: LockCurrencyPriceInput },
) {
const lockedAt = new Date()
const sourceAmount = Number(args.input.sourceAmount)
if (!Number.isFinite(sourceAmount) || sourceAmount < 0) {
throw new GraphQLError('sourceAmount must be a non-negative finite number')
}
const sourceCurrency = currencyCode(args.input.sourceCurrency)
const policy = await pricingCurrencyForCountryValue(args.input.pricingCountryCode)
if (policy === null) {
throw new GraphQLError(
`Pricing currency policy is missing for country ${countryCode(args.input.pricingCountryCode)}`,
)
}
const clientCurrency = currencyCode(policy.currencyCode)
if (sourceCurrency === clientCurrency) {
const effectiveDate = args.input.rateDate
? historicalDate(args.input.rateDate)
: lockedAt
return {
sourceAmount,
sourceCurrency,
clientAmount: sourceAmount,
clientCurrency,
exchangeRate: 1,
rateSnapshotUuid: null,
rateEffectiveDate: effectiveDate.toISOString().slice(0, 10),
rateQuotedAt: effectiveDate.toISOString(),
rateLockedAt: lockedAt.toISOString(),
provider: 'identity',
attributionUrl: null,
pricingCountryCode: policy.countryCode,
}
}
const snapshot = args.input.rateDate
? await currencyRatesSnapshotForDate(historicalDate(args.input.rateDate))
: await latestCurrencyRatesSnapshot()
const exchangeRate = convertMoney(1, sourceCurrency, clientCurrency, snapshot)
return {
sourceAmount,
sourceCurrency,
clientAmount: sourceAmount * exchangeRate,
clientCurrency,
exchangeRate,
rateSnapshotUuid: snapshot.uuid,
rateEffectiveDate: snapshot.effectiveDate.toISOString().slice(0, 10),
rateQuotedAt: snapshot.updatedAt.toISOString(),
rateLockedAt: lockedAt.toISOString(),
provider: snapshot.provider,
attributionUrl: snapshot.attributionUrl,
pricingCountryCode: policy.countryCode,
}
}
export const m2mResolvers = {
Query: {
pricingCurrencyForCountry: (
_: unknown,
args: { countryCode: string },
) => pricingCurrencyForCountryValue(args.countryCode),
},
Mutation: {
lockCurrencyPrice,
},
}
+12 -3
View File
@@ -1,5 +1,6 @@
import { prisma } from './db.js'
import { convertPrices, displayCurrency } from './currency-rates.js'
import { latestCurrencyRatesSnapshot } from './currency-rate-store.js'
type SupplierInput = {
teamUuid?: string | null
@@ -37,6 +38,14 @@ type QuoteInput = {
notes?: string | null
}
async function convertCatalogPrices<T extends { pricePerUnit: unknown; currency: string }>(
rows: T[],
displayCurrencyValue: unknown,
) {
const snapshot = await latestCurrencyRatesSnapshot()
return convertPrices(rows, displayCurrencyValue, snapshot)
}
export const typeDefs = `#graphql
type Supplier {
uuid: ID!
@@ -200,7 +209,7 @@ async function productWithPriceSummaryFromProduct(
select: { pricePerUnit: true, currency: true },
take: 100,
})
const convertedQuotes = await convertPrices(quotes, displayCurrencyValue)
const convertedQuotes = await convertCatalogPrices(quotes, displayCurrencyValue)
const priceSeries = convertedQuotes.map(quote => quote.pricePerUnit).sort((a, b) => a - b)
const averagePrice = priceSeries.length === 0
? null
@@ -366,7 +375,7 @@ export const resolvers = {
orderBy: { createdAt: 'desc' },
})
return {
items: await convertPrices(quotes.slice(0, args.limit), rawArgs.displayCurrency),
items: await convertCatalogPrices(quotes.slice(0, args.limit), rawArgs.displayCurrency),
offset: args.offset,
limit: args.limit,
hasMore: quotes.length > args.limit,
@@ -379,7 +388,7 @@ export const resolvers = {
include: { supplier: true, product: true },
})
if (quote === null) return null
return (await convertPrices([quote], args.displayCurrency))[0]
return (await convertCatalogPrices([quote], args.displayCurrency))[0]
},
},