This commit is contained in:
@@ -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;
|
||||
@@ -3,8 +3,6 @@ import { Prisma } from '@prisma/client'
|
||||
import { prisma } from './db.js'
|
||||
import {
|
||||
currencyCode,
|
||||
currencyRates,
|
||||
historicalCurrencyRates,
|
||||
type CurrencyRatesSnapshot,
|
||||
} from './currency-rates.js'
|
||||
|
||||
@@ -38,7 +36,9 @@ function snapshotFromStored(item: StoredSnapshot): StoredCurrencyRatesSnapshot {
|
||||
provider: item.provider,
|
||||
documentation: item.documentation,
|
||||
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(
|
||||
snapshot: CurrencyRatesSnapshot,
|
||||
): Promise<StoredCurrencyRatesSnapshot> {
|
||||
@@ -110,9 +121,11 @@ export async function persistCurrencyRates(
|
||||
}
|
||||
|
||||
export async function latestCurrencyRatesSnapshot(): Promise<StoredCurrencyRatesSnapshot> {
|
||||
const latest = await currencyRates()
|
||||
const stored = await findStored(utcDate(latest.updatedAt))
|
||||
return stored === null ? persistCurrencyRates(latest) : snapshotFromStored(stored)
|
||||
const stored = await findLatestStored()
|
||||
if (stored === null) {
|
||||
throw new Error('Fixed currency rate snapshot is not configured')
|
||||
}
|
||||
return snapshotFromStored(stored)
|
||||
}
|
||||
|
||||
export async function currencyRatesSnapshotForDate(
|
||||
@@ -124,5 +137,7 @@ export async function currencyRatesSnapshotForDate(
|
||||
}
|
||||
const stored = await findStored(effectiveDate)
|
||||
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
@@ -1,21 +1,10 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
convertMoney,
|
||||
displayCurrency,
|
||||
parseCurrencyRates,
|
||||
parseHistoricalCurrencyRates,
|
||||
} from './currency-rates.js'
|
||||
import { convertMoney, displayCurrency } 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',
|
||||
const snapshot = {
|
||||
baseCurrency: 'USD' as const,
|
||||
rates: {
|
||||
USD: 1,
|
||||
RUB: 82.802865,
|
||||
@@ -23,41 +12,29 @@ const payload = {
|
||||
EUR: 0.85618,
|
||||
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', () => {
|
||||
const rates = parseCurrencyRates(payload)
|
||||
assert.equal(convertMoney(100, 'USD', 'RUB', rates), 8_280.2865)
|
||||
assert.equal(convertMoney(100, 'USD', 'RUB', snapshot), 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)
|
||||
assert.equal(convertMoney(82.802865, 'RUB', 'EUR', snapshot), 0.85618)
|
||||
})
|
||||
|
||||
test('rejects unsupported display currencies', () => {
|
||||
assert.throws(() => displayCurrency('GBP'), /Unsupported display currency/)
|
||||
})
|
||||
|
||||
test('rejects incomplete provider responses', () => {
|
||||
test('rejects snapshots without the requested rate', () => {
|
||||
assert.throws(
|
||||
() => parseCurrencyRates({ ...payload, rates: { ...payload.rates, RUB: 0 } }),
|
||||
/no valid RUB rate/,
|
||||
() => convertMoney(100, 'USD', 'GBP', snapshot),
|
||||
/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
@@ -2,30 +2,6 @@ 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
|
||||
}
|
||||
|
||||
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 = {
|
||||
baseCurrency: 'USD'
|
||||
rates: Record<string, number>
|
||||
@@ -34,15 +10,11 @@ export type CurrencyRatesSnapshot = {
|
||||
provider: string
|
||||
documentation: 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)
|
||||
|
||||
let cachedRates: CurrencyRatesSnapshot | null = null
|
||||
|
||||
export function displayCurrency(value: unknown): DisplayCurrency {
|
||||
const normalized = String(value ?? '').trim().toUpperCase()
|
||||
if (!displayCurrencyCodes.has(normalized)) {
|
||||
@@ -59,102 +31,6 @@ export function currencyCode(value: unknown): string {
|
||||
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(
|
||||
amount: number,
|
||||
sourceCurrency: string,
|
||||
@@ -180,14 +56,13 @@ export function convertMoney(
|
||||
export async function convertPrices<T extends { pricePerUnit: unknown; currency: string }>(
|
||||
rows: T[],
|
||||
targetCurrencyValue: unknown,
|
||||
providedSnapshot?: CurrencyRatesSnapshot,
|
||||
snapshot: CurrencyRatesSnapshot,
|
||||
) {
|
||||
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 = providedSnapshot ?? await currencyRates()
|
||||
return rows.map(row => ({
|
||||
...row,
|
||||
pricePerUnit: convertMoney(Number(row.pricePerUnit), row.currency, targetCurrency, snapshot),
|
||||
|
||||
Reference in New Issue
Block a user