Add catalog display currency conversion
Build and deploy Docker image / build (push) Successful in 2m2s
Build and deploy Docker image / build (push) Successful in 2m2s
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "ExchangeRate-API currency rates",
|
||||
"type": "collection"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule graphql-contracts updated: 571a2c8172...d22dff8e1f
@@ -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/,
|
||||
)
|
||||
})
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user