Add catalog display currency conversion
Build and deploy Docker image / build (push) Successful in 2m2s

This commit is contained in:
Ruslan Bakiev
2026-08-24 16:48:23 +07:00
parent 02c6df0d02
commit d223845310
7 changed files with 317 additions and 8 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"version": "1",
"name": "ExchangeRate-API currency rates",
"type": "collection"
}
+18
View File
@@ -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
}
@@ -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
}
}
+42
View File
@@ -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/,
)
})
+117
View File
@@ -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<string, number>
'error-type'?: string
}
export type CurrencyRatesSnapshot = {
baseCurrency: 'USD'
rates: Record<string, number>
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<string>(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<CurrencyRatesSnapshot> {
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<T extends { pricePerUnit: unknown; currency: string }>(
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,
}))
}
+116 -7
View File
@@ -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<ReturnType<typeof prisma.product.findUnique>> & {},
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: {