diff --git a/bruno/currency-rates/historical-usd.bru b/bruno/currency-rates/historical-usd.bru new file mode 100644 index 0000000..de20420 --- /dev/null +++ b/bruno/currency-rates/historical-usd.bru @@ -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 +} diff --git a/graphql-contracts b/graphql-contracts index d22dff8..09de97f 160000 --- a/graphql-contracts +++ b/graphql-contracts @@ -1 +1 @@ -Subproject commit d22dff8e1fc63b4f26f241cd9d650f18175e81ee +Subproject commit 09de97f9fa7ec727474f982121dbd72bddd3fa0a diff --git a/prisma/migrations/3_add_currency_rate_history/migration.sql b/prisma/migrations/3_add_currency_rate_history/migration.sql new file mode 100644 index 0000000..6c89ec3 --- /dev/null +++ b/prisma/migrations/3_add_currency_rate_history/migration.sql @@ -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); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 576dc19..d69913d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/src/currency-rate-store.ts b/src/currency-rate-store.ts new file mode 100644 index 0000000..4eee408 --- /dev/null +++ b/src/currency-rate-store.ts @@ -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 { + return prisma.currencyRateSnapshot.findUnique({ + where: { + baseCurrency_effectiveDate: { + baseCurrency: 'USD', + effectiveDate, + }, + }, + include: snapshotInclude, + }) +} + +export async function persistCurrencyRates( + snapshot: CurrencyRatesSnapshot, +): Promise { + 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 { + 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 { + 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)) +} diff --git a/src/currency-rates.test.ts b/src/currency-rates.test.ts index c97d27b..441576b 100644 --- a/src/currency-rates.test.ts +++ b/src/currency-rates.test.ts @@ -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) +}) diff --git a/src/currency-rates.ts b/src/currency-rates.ts index b165c8b..db4f4b3 100644 --- a/src/currency-rates.ts +++ b/src/currency-rates.ts @@ -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 + 'error-type'?: string +} + export type CurrencyRatesSnapshot = { baseCurrency: 'USD' rates: Record @@ -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(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 { + 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 { if (cachedRates && Date.now() < cachedRates.nextUpdateAt.getTime()) { return cachedRates @@ -84,7 +158,7 @@ export async function currencyRates(): Promise { 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( 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), diff --git a/src/index.ts b/src/index.ts index 7340058..7c670cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"); diff --git a/src/m2m-schema.ts b/src/m2m-schema.ts new file mode 100644 index 0000000..49e2831 --- /dev/null +++ b/src/m2m-schema.ts @@ -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, + }, +} diff --git a/src/schema.ts b/src/schema.ts index 115c7f2..a47c142 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -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( + 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] }, },