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 { prisma } from './db.js'
|
||||||
|
import { convertPrices, displayCurrency } from './currency-rates.js'
|
||||||
|
|
||||||
type SupplierInput = {
|
type SupplierInput = {
|
||||||
teamUuid?: string | null
|
teamUuid?: string | null
|
||||||
@@ -87,6 +88,20 @@ export const typeDefs = `#graphql
|
|||||||
updatedAt: String!
|
updatedAt: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CatalogProductPage {
|
||||||
|
items: [Product!]!
|
||||||
|
offset: Int!
|
||||||
|
limit: Int!
|
||||||
|
hasMore: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
type CatalogQuotePage {
|
||||||
|
items: [Quote!]!
|
||||||
|
offset: Int!
|
||||||
|
limit: Int!
|
||||||
|
hasMore: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
input SupplierInput {
|
input SupplierInput {
|
||||||
teamUuid: String
|
teamUuid: String
|
||||||
name: String!
|
name: String!
|
||||||
@@ -130,6 +145,10 @@ export const typeDefs = `#graphql
|
|||||||
product(uuid: ID!): Product
|
product(uuid: ID!): Product
|
||||||
quotes(productUuid: ID, supplierUuid: ID, status: String, limit: Int, offset: Int): [Quote!]!
|
quotes(productUuid: ID, supplierUuid: ID, status: String, limit: Int, offset: Int): [Quote!]!
|
||||||
quote(uuid: ID!): 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 {
|
type Mutation {
|
||||||
@@ -172,17 +191,17 @@ function productData(input: ProductInput) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function productWithPriceSummary(uuid: string) {
|
async function productWithPriceSummaryFromProduct(
|
||||||
const product = await prisma.product.findUnique({ where: { uuid } })
|
product: Awaited<ReturnType<typeof prisma.product.findUnique>> & {},
|
||||||
if (product === null) return null
|
displayCurrencyValue: unknown,
|
||||||
|
) {
|
||||||
const quotes = await prisma.quote.findMany({
|
const quotes = await prisma.quote.findMany({
|
||||||
where: { productId: product.id, status: 'active' },
|
where: { productId: product.id, status: 'active' },
|
||||||
select: { pricePerUnit: true, currency: true },
|
select: { pricePerUnit: true, currency: true },
|
||||||
orderBy: { pricePerUnit: 'asc' },
|
|
||||||
take: 100,
|
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
|
const averagePrice = priceSeries.length === 0
|
||||||
? null
|
? null
|
||||||
: priceSeries.reduce((sum, price) => sum + price, 0) / priceSeries.length
|
: priceSeries.reduce((sum, price) => sum + price, 0) / priceSeries.length
|
||||||
@@ -196,10 +215,26 @@ async function productWithPriceSummary(uuid: string) {
|
|||||||
averagePrice,
|
averagePrice,
|
||||||
priceVariationPercent,
|
priceVariationPercent,
|
||||||
priceSeries,
|
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) {
|
async function quoteData(input: QuoteInput) {
|
||||||
const supplier = await prisma.supplier.findUnique({ where: { uuid: input.supplierUuid } })
|
const supplier = await prisma.supplier.findUnique({ where: { uuid: input.supplierUuid } })
|
||||||
if (supplier === null) throw new Error(`Supplier ${input.supplierUuid} does not exist`)
|
if (supplier === null) throw new Error(`Supplier ${input.supplierUuid} does not exist`)
|
||||||
@@ -272,6 +307,80 @@ export const resolvers = {
|
|||||||
|
|
||||||
quote: (_: unknown, args: { uuid: string }) =>
|
quote: (_: unknown, args: { uuid: string }) =>
|
||||||
prisma.quote.findUnique({ where: { uuid: args.uuid }, include: { supplier: true, product: true } }),
|
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: {
|
Mutation: {
|
||||||
|
|||||||
Reference in New Issue
Block a user