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 { 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
@@ -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
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user