diff --git a/bruno/currency-rates/bruno.json b/bruno/currency-rates/bruno.json new file mode 100644 index 0000000..daf6210 --- /dev/null +++ b/bruno/currency-rates/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "ExchangeRate-API currency rates", + "type": "collection" +} diff --git a/bruno/currency-rates/latest-usd.bru b/bruno/currency-rates/latest-usd.bru new file mode 100644 index 0000000..7c6d803 --- /dev/null +++ b/bruno/currency-rates/latest-usd.bru @@ -0,0 +1,18 @@ +meta { + name: Latest USD rates + type: http + seq: 1 +} + +get { + url: https://open.er-api.com/v6/latest/USD + body: none + auth: none +} + +assert { + res.status: eq 200 + res.body.result: eq success + res.body.base_code: eq USD + res.body.rates.USD: eq 1 +} diff --git a/bruno/currency-rates/responses/latest-usd.json b/bruno/currency-rates/responses/latest-usd.json new file mode 100644 index 0000000..d144e27 --- /dev/null +++ b/bruno/currency-rates/responses/latest-usd.json @@ -0,0 +1,18 @@ +{ + "result": "success", + "provider": "https://www.exchangerate-api.com", + "documentation": "https://www.exchangerate-api.com/docs/free", + "terms_of_use": "https://www.exchangerate-api.com/terms", + "time_last_update_unix": 1787529751, + "time_last_update_utc": "Mon, 24 Aug 2026 00:02:31 +0000", + "time_next_update_unix": 1787617021, + "time_next_update_utc": "Tue, 25 Aug 2026 00:17:01 +0000", + "base_code": "USD", + "rates": { + "USD": 1, + "RUB": 82.802865, + "CNY": 6.729114, + "EUR": 0.85618, + "AED": 3.6725 + } +} diff --git a/graphql-contracts b/graphql-contracts index 571a2c8..d22dff8 160000 --- a/graphql-contracts +++ b/graphql-contracts @@ -1 +1 @@ -Subproject commit 571a2c81725a5d6ba301061cb43eaef4e2af3208 +Subproject commit d22dff8e1fc63b4f26f241cd9d650f18175e81ee diff --git a/src/currency-rates.test.ts b/src/currency-rates.test.ts new file mode 100644 index 0000000..c97d27b --- /dev/null +++ b/src/currency-rates.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { convertMoney, displayCurrency, parseCurrencyRates } from './currency-rates.js' + +const payload = { + result: 'success', + provider: 'https://www.exchangerate-api.com', + documentation: 'https://www.exchangerate-api.com/docs/free', + terms_of_use: 'https://www.exchangerate-api.com/terms', + time_last_update_unix: 1_787_529_751, + time_next_update_unix: 1_787_617_021, + base_code: 'USD', + rates: { + USD: 1, + RUB: 82.802865, + CNY: 6.729114, + EUR: 0.85618, + AED: 3.6725, + }, +} + +test('converts USD into the selected display currency', () => { + const rates = parseCurrencyRates(payload) + assert.equal(convertMoney(100, 'USD', 'RUB', rates), 8_280.2865) +}) + +test('converts between two non-USD currencies through the USD base', () => { + const rates = parseCurrencyRates(payload) + assert.equal(convertMoney(82.802865, 'RUB', 'EUR', rates), 0.85618) +}) + +test('rejects unsupported display currencies', () => { + assert.throws(() => displayCurrency('GBP'), /Unsupported display currency/) +}) + +test('rejects incomplete provider responses', () => { + assert.throws( + () => parseCurrencyRates({ ...payload, rates: { ...payload.rates, RUB: 0 } }), + /no valid RUB rate/, + ) +}) diff --git a/src/currency-rates.ts b/src/currency-rates.ts new file mode 100644 index 0000000..b165c8b --- /dev/null +++ b/src/currency-rates.ts @@ -0,0 +1,117 @@ +export const DISPLAY_CURRENCIES = ['USD', 'RUB', 'CNY', 'EUR', 'AED'] as const + +export type DisplayCurrency = typeof DISPLAY_CURRENCIES[number] + +type ExchangeRateApiResponse = { + result?: string + provider?: string + documentation?: string + terms_of_use?: string + time_last_update_unix?: number + time_next_update_unix?: number + base_code?: string + rates?: Record + 'error-type'?: string +} + +export type CurrencyRatesSnapshot = { + baseCurrency: 'USD' + rates: Record + updatedAt: Date + nextUpdateAt: Date + provider: string + documentation: string + termsOfUse: string + attributionUrl: string +} + +const exchangeRateApiUrl = 'https://open.er-api.com/v6/latest/USD' +const displayCurrencyCodes = new Set(DISPLAY_CURRENCIES) + +let cachedRates: CurrencyRatesSnapshot | null = null + +export function displayCurrency(value: unknown): DisplayCurrency { + const normalized = String(value ?? '').trim().toUpperCase() + if (!displayCurrencyCodes.has(normalized)) { + throw new Error(`Unsupported display currency: ${normalized || 'empty'}`) + } + return normalized as DisplayCurrency +} + +export function parseCurrencyRates(payload: ExchangeRateApiResponse): CurrencyRatesSnapshot { + if (payload.result !== 'success') { + throw new Error(`ExchangeRate-API error: ${payload['error-type'] ?? payload.result ?? 'invalid response'}`) + } + if (payload.base_code !== 'USD') { + throw new Error(`ExchangeRate-API returned unexpected base currency: ${payload.base_code ?? 'empty'}`) + } + if (!payload.rates || !payload.time_last_update_unix || !payload.time_next_update_unix) { + throw new Error('ExchangeRate-API response is missing rates or update timestamps') + } + if (!payload.provider || !payload.documentation || !payload.terms_of_use) { + throw new Error('ExchangeRate-API response is missing attribution metadata') + } + for (const code of DISPLAY_CURRENCIES) { + const rate = payload.rates[code] + if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) { + throw new Error(`ExchangeRate-API response has no valid ${code} rate`) + } + } + return { + baseCurrency: 'USD', + rates: payload.rates, + updatedAt: new Date(payload.time_last_update_unix * 1000), + nextUpdateAt: new Date(payload.time_next_update_unix * 1000), + provider: payload.provider, + documentation: payload.documentation, + termsOfUse: payload.terms_of_use, + attributionUrl: 'https://www.exchangerate-api.com', + } +} + +export async function currencyRates(): Promise { + if (cachedRates && Date.now() < cachedRates.nextUpdateAt.getTime()) { + return cachedRates + } + const response = await fetch(exchangeRateApiUrl) + if (!response.ok) { + throw new Error(`ExchangeRate-API request failed: ${response.status}`) + } + cachedRates = parseCurrencyRates(await response.json() as ExchangeRateApiResponse) + return cachedRates +} + +export function convertMoney( + amount: number, + sourceCurrency: string, + targetCurrency: DisplayCurrency, + snapshot: CurrencyRatesSnapshot, +) { + if (!Number.isFinite(amount)) { + throw new Error(`Invalid money amount: ${amount}`) + } + const normalizedSource = sourceCurrency.trim().toUpperCase() + const sourceRate = snapshot.rates[normalizedSource] + 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'}`) + } + return amount / sourceRate * targetRate +} + +export async function convertPrices( + rows: T[], + targetCurrencyValue: unknown, +) { + 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() + return rows.map(row => ({ + ...row, + pricePerUnit: convertMoney(Number(row.pricePerUnit), row.currency, targetCurrency, snapshot), + currency: targetCurrency, + })) +} diff --git a/src/schema.ts b/src/schema.ts index 8f6544c..115c7f2 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,4 +1,5 @@ import { prisma } from './db.js' +import { convertPrices, displayCurrency } from './currency-rates.js' type SupplierInput = { teamUuid?: string | null @@ -87,6 +88,20 @@ export const typeDefs = `#graphql updatedAt: String! } + type CatalogProductPage { + items: [Product!]! + offset: Int! + limit: Int! + hasMore: Boolean! + } + + type CatalogQuotePage { + items: [Quote!]! + offset: Int! + limit: Int! + hasMore: Boolean! + } + input SupplierInput { teamUuid: String name: String! @@ -130,6 +145,10 @@ export const typeDefs = `#graphql product(uuid: ID!): Product quotes(productUuid: ID, supplierUuid: ID, status: String, limit: Int, offset: Int): [Quote!]! quote(uuid: ID!): Quote + catalogProducts(displayCurrency: String!, limit: Int!, offset: Int!): CatalogProductPage! + catalogProduct(uuid: ID!, displayCurrency: String!): Product + catalogQuotes(displayCurrency: String!, productUuid: ID, supplierUuid: ID, status: String, limit: Int!, offset: Int!): CatalogQuotePage! + catalogQuote(uuid: ID!, displayCurrency: String!): Quote } type Mutation { @@ -172,17 +191,17 @@ function productData(input: ProductInput) { } } -async function productWithPriceSummary(uuid: string) { - const product = await prisma.product.findUnique({ where: { uuid } }) - if (product === null) return null - +async function productWithPriceSummaryFromProduct( + product: Awaited> & {}, + displayCurrencyValue: unknown, +) { const quotes = await prisma.quote.findMany({ where: { productId: product.id, status: 'active' }, select: { pricePerUnit: true, currency: true }, - orderBy: { pricePerUnit: 'asc' }, take: 100, }) - const priceSeries = quotes.map(quote => Number(quote.pricePerUnit)) + const convertedQuotes = await convertPrices(quotes, displayCurrencyValue) + const priceSeries = convertedQuotes.map(quote => quote.pricePerUnit).sort((a, b) => a - b) const averagePrice = priceSeries.length === 0 ? null : priceSeries.reduce((sum, price) => sum + price, 0) / priceSeries.length @@ -196,10 +215,26 @@ async function productWithPriceSummary(uuid: string) { averagePrice, priceVariationPercent, priceSeries, - currency: quotes[0]?.currency ?? 'USD', + currency: displayCurrency(displayCurrencyValue), } } +async function productWithPriceSummary(uuid: string, displayCurrencyValue: unknown = 'USD') { + const product = await prisma.product.findUnique({ where: { uuid } }) + if (product === null) return null + return productWithPriceSummaryFromProduct(product, displayCurrencyValue) +} + +function pageArgs(args: { limit: number; offset: number }) { + if (!Number.isInteger(args.limit) || args.limit < 1 || args.limit > 100) { + throw new Error('limit must be between 1 and 100') + } + if (!Number.isInteger(args.offset) || args.offset < 0) { + throw new Error('offset must be zero or greater') + } + return args +} + async function quoteData(input: QuoteInput) { const supplier = await prisma.supplier.findUnique({ where: { uuid: input.supplierUuid } }) if (supplier === null) throw new Error(`Supplier ${input.supplierUuid} does not exist`) @@ -272,6 +307,80 @@ export const resolvers = { quote: (_: unknown, args: { uuid: string }) => prisma.quote.findUnique({ where: { uuid: args.uuid }, include: { supplier: true, product: true } }), + + catalogProducts: async (_: unknown, rawArgs: { displayCurrency: string; limit: number; offset: number }) => { + const args = pageArgs(rawArgs) + const products = await prisma.product.findMany({ + where: { isActive: true }, + take: args.limit + 1, + skip: args.offset, + orderBy: { name: 'asc' }, + }) + const visibleProducts = products.slice(0, args.limit) + return { + items: await Promise.all( + visibleProducts.map(product => productWithPriceSummaryFromProduct(product, rawArgs.displayCurrency)), + ), + offset: args.offset, + limit: args.limit, + hasMore: products.length > args.limit, + } + }, + + catalogProduct: (_: unknown, args: { uuid: string; displayCurrency: string }) => + productWithPriceSummary(args.uuid, args.displayCurrency), + + catalogQuotes: async ( + _: unknown, + rawArgs: { + displayCurrency: string + productUuid?: string + supplierUuid?: string + status?: string + limit: number + offset: number + }, + ) => { + const args = pageArgs(rawArgs) + const product = rawArgs.productUuid + ? await prisma.product.findUnique({ where: { uuid: rawArgs.productUuid } }) + : null + const supplier = rawArgs.supplierUuid + ? await prisma.supplier.findUnique({ where: { uuid: rawArgs.supplierUuid } }) + : null + if (rawArgs.productUuid && product === null) { + return { items: [], offset: args.offset, limit: args.limit, hasMore: false } + } + if (rawArgs.supplierUuid && supplier === null) { + return { items: [], offset: args.offset, limit: args.limit, hasMore: false } + } + const quotes = await prisma.quote.findMany({ + where: { + status: rawArgs.status ?? 'active', + ...(product ? { productId: product.id } : {}), + ...(supplier ? { supplierId: supplier.id } : {}), + }, + include: { supplier: true, product: true }, + take: args.limit + 1, + skip: args.offset, + orderBy: { createdAt: 'desc' }, + }) + return { + items: await convertPrices(quotes.slice(0, args.limit), rawArgs.displayCurrency), + offset: args.offset, + limit: args.limit, + hasMore: quotes.length > args.limit, + } + }, + + catalogQuote: async (_: unknown, args: { uuid: string; displayCurrency: string }) => { + const quote = await prisma.quote.findUnique({ + where: { uuid: args.uuid }, + include: { supplier: true, product: true }, + }) + if (quote === null) return null + return (await convertPrices([quote], args.displayCurrency))[0] + }, }, Mutation: {