Use fixed demo currency snapshot
Build and deploy Docker image / build (push) Successful in 1m13s

This commit is contained in:
Ruslan Bakiev
2026-08-24 18:15:32 +07:00
parent bd06ee04c0
commit ca0e70d723
4 changed files with 75 additions and 171 deletions
@@ -0,0 +1,37 @@
-- Seed the fixed 2026-08-24 demo snapshot. Runtime pricing reads only this table.
INSERT INTO "exchange_currency_rate_snapshots" (
"uuid",
"base_currency",
"effective_date",
"quoted_at",
"next_update_at",
"provider",
"documentation",
"terms_of_use"
)
VALUES (
'00000000-0000-4000-8000-202608240001',
'USD',
DATE '2026-08-24',
TIMESTAMP '2026-08-24 00:02:31',
NULL,
'demo-fixed',
'internal demo snapshot',
'demo only'
)
ON CONFLICT ("base_currency", "effective_date") DO NOTHING;
INSERT INTO "exchange_currency_rates" ("snapshot_id", "quote_currency", "rate")
SELECT snapshot."id", rate."quote_currency", rate."rate"
FROM "exchange_currency_rate_snapshots" AS snapshot
CROSS JOIN (
VALUES
('USD', 1.0000000000::DECIMAL(24,10)),
('RUB', 82.8028650000::DECIMAL(24,10)),
('CNY', 6.7291140000::DECIMAL(24,10)),
('EUR', 0.8561800000::DECIMAL(24,10)),
('AED', 3.6725000000::DECIMAL(24,10))
) AS rate("quote_currency", "rate")
WHERE snapshot."base_currency" = 'USD'
AND snapshot."effective_date" = DATE '2026-08-24'
ON CONFLICT ("snapshot_id", "quote_currency") DO NOTHING;
+22 -7
View File
@@ -3,8 +3,6 @@ import { Prisma } from '@prisma/client'
import { prisma } from './db.js' import { prisma } from './db.js'
import { import {
currencyCode, currencyCode,
currencyRates,
historicalCurrencyRates,
type CurrencyRatesSnapshot, type CurrencyRatesSnapshot,
} from './currency-rates.js' } from './currency-rates.js'
@@ -38,7 +36,9 @@ function snapshotFromStored(item: StoredSnapshot): StoredCurrencyRatesSnapshot {
provider: item.provider, provider: item.provider,
documentation: item.documentation, documentation: item.documentation,
termsOfUse: item.termsOfUse, termsOfUse: item.termsOfUse,
attributionUrl: 'https://www.exchangerate-api.com', attributionUrl: item.provider === 'demo-fixed'
? null
: 'https://www.exchangerate-api.com',
} }
} }
@@ -67,6 +67,17 @@ async function findStored(effectiveDate: Date): Promise<StoredSnapshot | null> {
}) })
} }
async function findLatestStored(): Promise<StoredSnapshot | null> {
return prisma.currencyRateSnapshot.findFirst({
where: {
baseCurrency: 'USD',
effectiveDate: { lte: utcDate(new Date()) },
},
orderBy: { effectiveDate: 'desc' },
include: snapshotInclude,
})
}
export async function persistCurrencyRates( export async function persistCurrencyRates(
snapshot: CurrencyRatesSnapshot, snapshot: CurrencyRatesSnapshot,
): Promise<StoredCurrencyRatesSnapshot> { ): Promise<StoredCurrencyRatesSnapshot> {
@@ -110,9 +121,11 @@ export async function persistCurrencyRates(
} }
export async function latestCurrencyRatesSnapshot(): Promise<StoredCurrencyRatesSnapshot> { export async function latestCurrencyRatesSnapshot(): Promise<StoredCurrencyRatesSnapshot> {
const latest = await currencyRates() const stored = await findLatestStored()
const stored = await findStored(utcDate(latest.updatedAt)) if (stored === null) {
return stored === null ? persistCurrencyRates(latest) : snapshotFromStored(stored) throw new Error('Fixed currency rate snapshot is not configured')
}
return snapshotFromStored(stored)
} }
export async function currencyRatesSnapshotForDate( export async function currencyRatesSnapshotForDate(
@@ -124,5 +137,7 @@ export async function currencyRatesSnapshotForDate(
} }
const stored = await findStored(effectiveDate) const stored = await findStored(effectiveDate)
if (stored !== null) return snapshotFromStored(stored) if (stored !== null) return snapshotFromStored(stored)
return persistCurrencyRates(await historicalCurrencyRates(effectiveDate)) throw new Error(
`Currency rate snapshot is not configured for ${effectiveDate.toISOString().slice(0, 10)}`,
)
} }
+14 -37
View File
@@ -1,21 +1,10 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { import { convertMoney, displayCurrency } from './currency-rates.js'
convertMoney,
displayCurrency,
parseCurrencyRates,
parseHistoricalCurrencyRates,
} from './currency-rates.js'
const payload = { const snapshot = {
result: 'success', baseCurrency: 'USD' as const,
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: { rates: {
USD: 1, USD: 1,
RUB: 82.802865, RUB: 82.802865,
@@ -23,41 +12,29 @@ const payload = {
EUR: 0.85618, EUR: 0.85618,
AED: 3.6725, AED: 3.6725,
}, },
updatedAt: new Date('2026-08-24T00:00:00.000Z'),
nextUpdateAt: new Date('2026-08-24T00:00:00.000Z'),
provider: 'demo-fixed',
documentation: 'internal',
termsOfUse: 'demo',
attributionUrl: null,
} }
test('converts USD into the selected display currency', () => { test('converts USD into the selected display currency', () => {
const rates = parseCurrencyRates(payload) assert.equal(convertMoney(100, 'USD', 'RUB', snapshot), 8_280.2865)
assert.equal(convertMoney(100, 'USD', 'RUB', rates), 8_280.2865)
}) })
test('converts between two non-USD currencies through the USD base', () => { test('converts between two non-USD currencies through the USD base', () => {
const rates = parseCurrencyRates(payload) assert.equal(convertMoney(82.802865, 'RUB', 'EUR', snapshot), 0.85618)
assert.equal(convertMoney(82.802865, 'RUB', 'EUR', rates), 0.85618)
}) })
test('rejects unsupported display currencies', () => { test('rejects unsupported display currencies', () => {
assert.throws(() => displayCurrency('GBP'), /Unsupported display currency/) assert.throws(() => displayCurrency('GBP'), /Unsupported display currency/)
}) })
test('rejects incomplete provider responses', () => { test('rejects snapshots without the requested rate', () => {
assert.throws( assert.throws(
() => parseCurrencyRates({ ...payload, rates: { ...payload.rates, RUB: 0 } }), () => convertMoney(100, 'USD', 'GBP', snapshot),
/no valid RUB rate/, /No exchange rate for target currency: GBP/,
) )
}) })
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)
})
+2 -127
View File
@@ -2,30 +2,6 @@ export const DISPLAY_CURRENCIES = ['USD', 'RUB', 'CNY', 'EUR', 'AED'] as const
export type DisplayCurrency = typeof DISPLAY_CURRENCIES[number] 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
}
type ExchangeRateApiHistoricalResponse = {
result?: string
documentation?: string
terms_of_use?: string
year?: number
month?: number
day?: number
base_code?: string
conversion_rates?: Record<string, number>
'error-type'?: string
}
export type CurrencyRatesSnapshot = { export type CurrencyRatesSnapshot = {
baseCurrency: 'USD' baseCurrency: 'USD'
rates: Record<string, number> rates: Record<string, number>
@@ -34,15 +10,11 @@ export type CurrencyRatesSnapshot = {
provider: string provider: string
documentation: string documentation: string
termsOfUse: string termsOfUse: string
attributionUrl: string attributionUrl: string | null
} }
const exchangeRateApiUrl = 'https://open.er-api.com/v6/latest/USD'
const exchangeRateApiProvider = 'https://www.exchangerate-api.com'
const displayCurrencyCodes = new Set<string>(DISPLAY_CURRENCIES) const displayCurrencyCodes = new Set<string>(DISPLAY_CURRENCIES)
let cachedRates: CurrencyRatesSnapshot | null = null
export function displayCurrency(value: unknown): DisplayCurrency { export function displayCurrency(value: unknown): DisplayCurrency {
const normalized = String(value ?? '').trim().toUpperCase() const normalized = String(value ?? '').trim().toUpperCase()
if (!displayCurrencyCodes.has(normalized)) { if (!displayCurrencyCodes.has(normalized)) {
@@ -59,102 +31,6 @@ export function currencyCode(value: unknown): string {
return normalized 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'}`)
}
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: 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<CurrencyRatesSnapshot> {
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<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( export function convertMoney(
amount: number, amount: number,
sourceCurrency: string, sourceCurrency: string,
@@ -180,14 +56,13 @@ export function convertMoney(
export async function convertPrices<T extends { pricePerUnit: unknown; currency: string }>( export async function convertPrices<T extends { pricePerUnit: unknown; currency: string }>(
rows: T[], rows: T[],
targetCurrencyValue: unknown, targetCurrencyValue: unknown,
providedSnapshot?: CurrencyRatesSnapshot, snapshot: CurrencyRatesSnapshot,
) { ) {
const targetCurrency = displayCurrency(targetCurrencyValue) const targetCurrency = displayCurrency(targetCurrencyValue)
const needsConversion = rows.some(row => row.currency.trim().toUpperCase() !== targetCurrency) const needsConversion = rows.some(row => row.currency.trim().toUpperCase() !== targetCurrency)
if (!needsConversion) { if (!needsConversion) {
return rows.map(row => ({ ...row, pricePerUnit: Number(row.pricePerUnit), currency: targetCurrency })) return rows.map(row => ({ ...row, pricePerUnit: Number(row.pricePerUnit), currency: targetCurrency }))
} }
const snapshot = providedSnapshot ?? await currencyRates()
return rows.map(row => ({ return rows.map(row => ({
...row, ...row,
pricePerUnit: convertMoney(Number(row.pricePerUnit), row.currency, targetCurrency, snapshot), pricePerUnit: convertMoney(Number(row.pricePerUnit), row.currency, targetCurrency, snapshot),