diff --git a/prisma/migrations/4_seed_demo_currency_rates/migration.sql b/prisma/migrations/4_seed_demo_currency_rates/migration.sql new file mode 100644 index 0000000..bf8ff0c --- /dev/null +++ b/prisma/migrations/4_seed_demo_currency_rates/migration.sql @@ -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; diff --git a/src/currency-rate-store.ts b/src/currency-rate-store.ts index 4eee408..749ae84 100644 --- a/src/currency-rate-store.ts +++ b/src/currency-rate-store.ts @@ -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 { }) } +async function findLatestStored(): Promise { + return prisma.currencyRateSnapshot.findFirst({ + where: { + baseCurrency: 'USD', + effectiveDate: { lte: utcDate(new Date()) }, + }, + orderBy: { effectiveDate: 'desc' }, + include: snapshotInclude, + }) +} + export async function persistCurrencyRates( snapshot: CurrencyRatesSnapshot, ): Promise { @@ -110,9 +121,11 @@ export async function persistCurrencyRates( } export async function latestCurrencyRatesSnapshot(): Promise { - 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)}`, + ) } diff --git a/src/currency-rates.test.ts b/src/currency-rates.test.ts index 441576b..c0a5724 100644 --- a/src/currency-rates.test.ts +++ b/src/currency-rates.test.ts @@ -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) -}) diff --git a/src/currency-rates.ts b/src/currency-rates.ts index db4f4b3..ad96e3b 100644 --- a/src/currency-rates.ts +++ b/src/currency-rates.ts @@ -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 - '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 - 'error-type'?: string -} - export type CurrencyRatesSnapshot = { baseCurrency: 'USD' rates: Record @@ -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(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 { - 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 { - 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( 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),