Compare commits
4
Commits
d223845310
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d48cec9abf | ||
|
|
fe883afde2 | ||
|
|
ca0e70d723 | ||
|
|
bd06ee04c0 |
@@ -0,0 +1,18 @@
|
|||||||
|
meta {
|
||||||
|
name: Historical USD rates
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: https://v6.exchangerate-api.com/v6/{{exchangeRateApiKey}}/history/USD/{{year}}/{{month}}/{{day}}
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
res.status: eq 200
|
||||||
|
res.body.result: eq success
|
||||||
|
res.body.base_code: eq USD
|
||||||
|
res.body.conversion_rates.USD: eq 1
|
||||||
|
}
|
||||||
+1
-1
Submodule graphql-contracts updated: d22dff8e1f...d8f6796b68
@@ -0,0 +1,116 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "exchange_currency_rate_snapshots" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"uuid" TEXT NOT NULL,
|
||||||
|
"base_currency" VARCHAR(3) NOT NULL,
|
||||||
|
"effective_date" DATE NOT NULL,
|
||||||
|
"quoted_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"next_update_at" TIMESTAMP(3),
|
||||||
|
"provider" VARCHAR(255) NOT NULL,
|
||||||
|
"documentation" VARCHAR(500) NOT NULL,
|
||||||
|
"terms_of_use" VARCHAR(500) NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "exchange_currency_rate_snapshots_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "exchange_currency_rates" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"snapshot_id" INTEGER NOT NULL,
|
||||||
|
"quote_currency" VARCHAR(3) NOT NULL,
|
||||||
|
"rate" DECIMAL(24,10) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "exchange_currency_rates_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "exchange_pricing_currency_policies" (
|
||||||
|
"country_code" VARCHAR(2) NOT NULL,
|
||||||
|
"currency_code" VARCHAR(3) NOT NULL,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "exchange_pricing_currency_policies_pkey" PRIMARY KEY ("country_code")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "exchange_currency_rate_snapshots_uuid_key" ON "exchange_currency_rate_snapshots"("uuid");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "exchange_currency_rate_snapshots_effective_date_idx" ON "exchange_currency_rate_snapshots"("effective_date");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "exchange_currency_rate_snapshots_base_currency_effective_da_key" ON "exchange_currency_rate_snapshots"("base_currency", "effective_date");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "exchange_currency_rates_quote_currency_idx" ON "exchange_currency_rates"("quote_currency");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "exchange_currency_rates_snapshot_id_quote_currency_key" ON "exchange_currency_rates"("snapshot_id", "quote_currency");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "exchange_pricing_currency_policies_currency_code_idx" ON "exchange_pricing_currency_policies"("currency_code");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "exchange_currency_rates" ADD CONSTRAINT "exchange_currency_rates_snapshot_id_fkey" FOREIGN KEY ("snapshot_id") REFERENCES "exchange_currency_rate_snapshots"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Seed pricing currencies for countries already present in Optovia data.
|
||||||
|
INSERT INTO "exchange_pricing_currency_policies" ("country_code", "currency_code", "updated_at") VALUES
|
||||||
|
('AE', 'AED', CURRENT_TIMESTAMP),
|
||||||
|
('AO', 'AOA', CURRENT_TIMESTAMP),
|
||||||
|
('BF', 'XOF', CURRENT_TIMESTAMP),
|
||||||
|
('BI', 'BIF', CURRENT_TIMESTAMP),
|
||||||
|
('BJ', 'XOF', CURRENT_TIMESTAMP),
|
||||||
|
('BW', 'BWP', CURRENT_TIMESTAMP),
|
||||||
|
('CD', 'CDF', CURRENT_TIMESTAMP),
|
||||||
|
('CF', 'XAF', CURRENT_TIMESTAMP),
|
||||||
|
('CG', 'XAF', CURRENT_TIMESTAMP),
|
||||||
|
('CI', 'XOF', CURRENT_TIMESTAMP),
|
||||||
|
('CM', 'XAF', CURRENT_TIMESTAMP),
|
||||||
|
('CN', 'CNY', CURRENT_TIMESTAMP),
|
||||||
|
('CV', 'CVE', CURRENT_TIMESTAMP),
|
||||||
|
('DJ', 'DJF', CURRENT_TIMESTAMP),
|
||||||
|
('DZ', 'DZD', CURRENT_TIMESTAMP),
|
||||||
|
('EG', 'EGP', CURRENT_TIMESTAMP),
|
||||||
|
('ET', 'ETB', CURRENT_TIMESTAMP),
|
||||||
|
('GA', 'XAF', CURRENT_TIMESTAMP),
|
||||||
|
('GH', 'GHS', CURRENT_TIMESTAMP),
|
||||||
|
('GM', 'GMD', CURRENT_TIMESTAMP),
|
||||||
|
('GN', 'GNF', CURRENT_TIMESTAMP),
|
||||||
|
('GQ', 'XAF', CURRENT_TIMESTAMP),
|
||||||
|
('GW', 'XOF', CURRENT_TIMESTAMP),
|
||||||
|
('KE', 'KES', CURRENT_TIMESTAMP),
|
||||||
|
('KM', 'KMF', CURRENT_TIMESTAMP),
|
||||||
|
('KZ', 'KZT', CURRENT_TIMESTAMP),
|
||||||
|
('LR', 'LRD', CURRENT_TIMESTAMP),
|
||||||
|
('LS', 'LSL', CURRENT_TIMESTAMP),
|
||||||
|
('LY', 'LYD', CURRENT_TIMESTAMP),
|
||||||
|
('MA', 'MAD', CURRENT_TIMESTAMP),
|
||||||
|
('MG', 'MGA', CURRENT_TIMESTAMP),
|
||||||
|
('ML', 'XOF', CURRENT_TIMESTAMP),
|
||||||
|
('MR', 'MRU', CURRENT_TIMESTAMP),
|
||||||
|
('MU', 'MUR', CURRENT_TIMESTAMP),
|
||||||
|
('MW', 'MWK', CURRENT_TIMESTAMP),
|
||||||
|
('MZ', 'MZN', CURRENT_TIMESTAMP),
|
||||||
|
('NA', 'NAD', CURRENT_TIMESTAMP),
|
||||||
|
('NE', 'XOF', CURRENT_TIMESTAMP),
|
||||||
|
('NG', 'NGN', CURRENT_TIMESTAMP),
|
||||||
|
('PL', 'PLN', CURRENT_TIMESTAMP),
|
||||||
|
('RU', 'RUB', CURRENT_TIMESTAMP),
|
||||||
|
('RW', 'RWF', CURRENT_TIMESTAMP),
|
||||||
|
('SC', 'SCR', CURRENT_TIMESTAMP),
|
||||||
|
('SD', 'SDG', CURRENT_TIMESTAMP),
|
||||||
|
('SL', 'SLE', CURRENT_TIMESTAMP),
|
||||||
|
('SN', 'XOF', CURRENT_TIMESTAMP),
|
||||||
|
('SO', 'SOS', CURRENT_TIMESTAMP),
|
||||||
|
('SS', 'SSP', CURRENT_TIMESTAMP),
|
||||||
|
('ST', 'STN', CURRENT_TIMESTAMP),
|
||||||
|
('SZ', 'SZL', CURRENT_TIMESTAMP),
|
||||||
|
('TD', 'XAF', CURRENT_TIMESTAMP),
|
||||||
|
('TG', 'XOF', CURRENT_TIMESTAMP),
|
||||||
|
('TN', 'TND', CURRENT_TIMESTAMP),
|
||||||
|
('TZ', 'TZS', CURRENT_TIMESTAMP),
|
||||||
|
('UG', 'UGX', CURRENT_TIMESTAMP),
|
||||||
|
('ZA', 'ZAR', CURRENT_TIMESTAMP),
|
||||||
|
('ZM', 'ZMW', CURRENT_TIMESTAMP),
|
||||||
|
('ZW', 'ZWG', CURRENT_TIMESTAMP);
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- DropTable
|
||||||
|
DROP TABLE "exchange_pricing_currency_policies";
|
||||||
@@ -69,3 +69,33 @@ model Product {
|
|||||||
@@index([categoryName, isActive])
|
@@index([categoryName, isActive])
|
||||||
@@map("exchange_products")
|
@@map("exchange_products")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model CurrencyRateSnapshot {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
uuid String @unique @default(uuid())
|
||||||
|
baseCurrency String @map("base_currency") @db.VarChar(3)
|
||||||
|
effectiveDate DateTime @map("effective_date") @db.Date
|
||||||
|
quotedAt DateTime @map("quoted_at")
|
||||||
|
nextUpdateAt DateTime? @map("next_update_at")
|
||||||
|
provider String @db.VarChar(255)
|
||||||
|
documentation String @db.VarChar(500)
|
||||||
|
termsOfUse String @map("terms_of_use") @db.VarChar(500)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
rates CurrencyRate[]
|
||||||
|
|
||||||
|
@@unique([baseCurrency, effectiveDate])
|
||||||
|
@@index([effectiveDate])
|
||||||
|
@@map("exchange_currency_rate_snapshots")
|
||||||
|
}
|
||||||
|
|
||||||
|
model CurrencyRate {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
snapshotId Int @map("snapshot_id")
|
||||||
|
snapshot CurrencyRateSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Cascade)
|
||||||
|
quoteCurrency String @map("quote_currency") @db.VarChar(3)
|
||||||
|
rate Decimal @db.Decimal(24, 10)
|
||||||
|
|
||||||
|
@@unique([snapshotId, quoteCurrency])
|
||||||
|
@@index([quoteCurrency])
|
||||||
|
@@map("exchange_currency_rates")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
import { prisma } from './db.js'
|
||||||
|
import {
|
||||||
|
currencyCode,
|
||||||
|
type CurrencyRatesSnapshot,
|
||||||
|
} from './currency-rates.js'
|
||||||
|
|
||||||
|
const snapshotInclude = {
|
||||||
|
rates: true,
|
||||||
|
} satisfies Prisma.CurrencyRateSnapshotInclude
|
||||||
|
|
||||||
|
type StoredSnapshot = Prisma.CurrencyRateSnapshotGetPayload<{
|
||||||
|
include: typeof snapshotInclude
|
||||||
|
}>
|
||||||
|
|
||||||
|
export type StoredCurrencyRatesSnapshot = CurrencyRatesSnapshot & {
|
||||||
|
uuid: string
|
||||||
|
effectiveDate: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
function utcDate(value: Date): Date {
|
||||||
|
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()))
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotFromStored(item: StoredSnapshot): StoredCurrencyRatesSnapshot {
|
||||||
|
return {
|
||||||
|
uuid: item.uuid,
|
||||||
|
baseCurrency: 'USD',
|
||||||
|
effectiveDate: item.effectiveDate,
|
||||||
|
rates: Object.fromEntries(
|
||||||
|
item.rates.map(rate => [rate.quoteCurrency, Number(rate.rate)]),
|
||||||
|
),
|
||||||
|
updatedAt: item.quotedAt,
|
||||||
|
nextUpdateAt: item.nextUpdateAt ?? item.quotedAt,
|
||||||
|
provider: item.provider,
|
||||||
|
documentation: item.documentation,
|
||||||
|
termsOfUse: item.termsOfUse,
|
||||||
|
attributionUrl: item.provider === 'demo-fixed'
|
||||||
|
? null
|
||||||
|
: 'https://www.exchangerate-api.com',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rateRows(snapshot: CurrencyRatesSnapshot) {
|
||||||
|
return Object.entries(snapshot.rates).map(([rawCurrency, rate]) => {
|
||||||
|
const quoteCurrency = currencyCode(rawCurrency)
|
||||||
|
if (!Number.isFinite(rate) || rate <= 0) {
|
||||||
|
throw new Error(`Invalid ${quoteCurrency} exchange rate: ${rate}`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
quoteCurrency,
|
||||||
|
rate: new Prisma.Decimal(rate),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findStored(effectiveDate: Date): Promise<StoredSnapshot | null> {
|
||||||
|
return prisma.currencyRateSnapshot.findUnique({
|
||||||
|
where: {
|
||||||
|
baseCurrency_effectiveDate: {
|
||||||
|
baseCurrency: 'USD',
|
||||||
|
effectiveDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: snapshotInclude,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
const effectiveDate = utcDate(snapshot.updatedAt)
|
||||||
|
const rows = rateRows(snapshot)
|
||||||
|
const stored = await prisma.$transaction(async transaction => {
|
||||||
|
const item = await transaction.currencyRateSnapshot.upsert({
|
||||||
|
where: {
|
||||||
|
baseCurrency_effectiveDate: {
|
||||||
|
baseCurrency: snapshot.baseCurrency,
|
||||||
|
effectiveDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
baseCurrency: snapshot.baseCurrency,
|
||||||
|
effectiveDate,
|
||||||
|
quotedAt: snapshot.updatedAt,
|
||||||
|
nextUpdateAt: snapshot.nextUpdateAt,
|
||||||
|
provider: snapshot.provider,
|
||||||
|
documentation: snapshot.documentation,
|
||||||
|
termsOfUse: snapshot.termsOfUse,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
quotedAt: snapshot.updatedAt,
|
||||||
|
nextUpdateAt: snapshot.nextUpdateAt,
|
||||||
|
provider: snapshot.provider,
|
||||||
|
documentation: snapshot.documentation,
|
||||||
|
termsOfUse: snapshot.termsOfUse,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await transaction.currencyRate.deleteMany({ where: { snapshotId: item.id } })
|
||||||
|
await transaction.currencyRate.createMany({
|
||||||
|
data: rows.map(row => ({ snapshotId: item.id, ...row })),
|
||||||
|
})
|
||||||
|
return transaction.currencyRateSnapshot.findUniqueOrThrow({
|
||||||
|
where: { id: item.id },
|
||||||
|
include: snapshotInclude,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return snapshotFromStored(stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function latestCurrencyRatesSnapshot(): Promise<StoredCurrencyRatesSnapshot> {
|
||||||
|
const stored = await findLatestStored()
|
||||||
|
if (stored === null) {
|
||||||
|
throw new Error('Fixed currency rate snapshot is not configured')
|
||||||
|
}
|
||||||
|
return snapshotFromStored(stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function currencyRatesSnapshotForDate(
|
||||||
|
requestedDate: Date,
|
||||||
|
): Promise<StoredCurrencyRatesSnapshot> {
|
||||||
|
const effectiveDate = utcDate(requestedDate)
|
||||||
|
if (effectiveDate.getTime() > utcDate(new Date()).getTime()) {
|
||||||
|
throw new Error('Currency rate date cannot be in the future')
|
||||||
|
}
|
||||||
|
const stored = await findStored(effectiveDate)
|
||||||
|
if (stored !== null) return snapshotFromStored(stored)
|
||||||
|
throw new Error(
|
||||||
|
`Currency rate snapshot is not configured for ${effectiveDate.toISOString().slice(0, 10)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
+14
-16
@@ -1,16 +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 { convertMoney, displayCurrency, parseCurrencyRates } from './currency-rates.js'
|
import { convertMoney, displayCurrency } 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,
|
||||||
@@ -18,25 +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/,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
+12
-58
@@ -2,18 +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
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CurrencyRatesSnapshot = {
|
export type CurrencyRatesSnapshot = {
|
||||||
baseCurrency: 'USD'
|
baseCurrency: 'USD'
|
||||||
rates: Record<string, number>
|
rates: Record<string, number>
|
||||||
@@ -22,14 +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 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)) {
|
||||||
@@ -38,53 +23,18 @@ export function displayCurrency(value: unknown): DisplayCurrency {
|
|||||||
return normalized as DisplayCurrency
|
return normalized as DisplayCurrency
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseCurrencyRates(payload: ExchangeRateApiResponse): CurrencyRatesSnapshot {
|
export function currencyCode(value: unknown): string {
|
||||||
if (payload.result !== 'success') {
|
const normalized = String(value ?? '').trim().toUpperCase()
|
||||||
throw new Error(`ExchangeRate-API error: ${payload['error-type'] ?? payload.result ?? 'invalid response'}`)
|
if (!/^[A-Z]{3}$/.test(normalized)) {
|
||||||
|
throw new Error(`Invalid ISO 4217 currency code: ${normalized || 'empty'}`)
|
||||||
}
|
}
|
||||||
if (payload.base_code !== 'USD') {
|
return normalized
|
||||||
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(
|
export function convertMoney(
|
||||||
amount: number,
|
amount: number,
|
||||||
sourceCurrency: string,
|
sourceCurrency: string,
|
||||||
targetCurrency: DisplayCurrency,
|
targetCurrencyValue: string,
|
||||||
snapshot: CurrencyRatesSnapshot,
|
snapshot: CurrencyRatesSnapshot,
|
||||||
) {
|
) {
|
||||||
if (!Number.isFinite(amount)) {
|
if (!Number.isFinite(amount)) {
|
||||||
@@ -92,23 +42,27 @@ export function convertMoney(
|
|||||||
}
|
}
|
||||||
const normalizedSource = sourceCurrency.trim().toUpperCase()
|
const normalizedSource = sourceCurrency.trim().toUpperCase()
|
||||||
const sourceRate = snapshot.rates[normalizedSource]
|
const sourceRate = snapshot.rates[normalizedSource]
|
||||||
|
const targetCurrency = currencyCode(targetCurrencyValue)
|
||||||
const targetRate = snapshot.rates[targetCurrency]
|
const targetRate = snapshot.rates[targetCurrency]
|
||||||
if (typeof sourceRate !== 'number' || !Number.isFinite(sourceRate) || sourceRate <= 0) {
|
if (typeof sourceRate !== 'number' || !Number.isFinite(sourceRate) || sourceRate <= 0) {
|
||||||
throw new Error(`No exchange rate for source currency: ${normalizedSource || 'empty'}`)
|
throw new Error(`No exchange rate for source currency: ${normalizedSource || 'empty'}`)
|
||||||
}
|
}
|
||||||
|
if (typeof targetRate !== 'number' || !Number.isFinite(targetRate) || targetRate <= 0) {
|
||||||
|
throw new Error(`No exchange rate for target currency: ${targetCurrency}`)
|
||||||
|
}
|
||||||
return amount / sourceRate * targetRate
|
return amount / sourceRate * targetRate
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
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 = 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),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import cors from "@fastify/cors";
|
|||||||
import mercurius from "mercurius";
|
import mercurius from "mercurius";
|
||||||
import * as Sentry from "@sentry/node";
|
import * as Sentry from "@sentry/node";
|
||||||
import { resolvers, typeDefs } from "./schema.js";
|
import { resolvers, typeDefs } from "./schema.js";
|
||||||
|
import { m2mResolvers, m2mTypeDefs } from "./m2m-schema.js";
|
||||||
|
|
||||||
const PORT = Number.parseInt(process.env.PORT || "8000", 10);
|
const PORT = Number.parseInt(process.env.PORT || "8000", 10);
|
||||||
const SENTRY_DSN = process.env.SENTRY_DSN || "";
|
const SENTRY_DSN = process.env.SENTRY_DSN || "";
|
||||||
@@ -30,9 +31,21 @@ await app.register(mercurius, {
|
|||||||
path: "/graphql",
|
path: "/graphql",
|
||||||
graphiql: true,
|
graphiql: true,
|
||||||
});
|
});
|
||||||
|
await app.register(
|
||||||
|
async (route) => {
|
||||||
|
await route.register(mercurius, {
|
||||||
|
schema: m2mTypeDefs,
|
||||||
|
resolvers: m2mResolvers,
|
||||||
|
path: "/",
|
||||||
|
graphiql: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ prefix: "/graphql/m2m" },
|
||||||
|
);
|
||||||
|
|
||||||
app.get("/health", async () => ({ status: "ok", service: "exchange" }));
|
app.get("/health", async () => ({ status: "ok", service: "exchange" }));
|
||||||
|
|
||||||
await app.listen({ port: PORT, host: "0.0.0.0" });
|
await app.listen({ port: PORT, host: "0.0.0.0" });
|
||||||
console.log(`Exchange server ready on port ${PORT}`);
|
console.log(`Exchange server ready on port ${PORT}`);
|
||||||
console.log(" /graphql - suppliers, products, quotes");
|
console.log(" /graphql - suppliers, products, quotes");
|
||||||
|
console.log(" /graphql/m2m - pricing currency and locked conversions");
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { GraphQLError } from 'graphql'
|
||||||
|
|
||||||
|
import {
|
||||||
|
currencyRatesSnapshotForDate,
|
||||||
|
latestCurrencyRatesSnapshot,
|
||||||
|
} from './currency-rate-store.js'
|
||||||
|
import { convertMoney, currencyCode } from './currency-rates.js'
|
||||||
|
import { prisma } from './db.js'
|
||||||
|
|
||||||
|
type LockCurrencyPriceInput = {
|
||||||
|
sourceAmount: number
|
||||||
|
sourceCurrency: string
|
||||||
|
clientCurrency: string
|
||||||
|
pricingCountryCode: string
|
||||||
|
rateDate?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConvertCurrencyForDisplayInput = {
|
||||||
|
amount: number
|
||||||
|
sourceCurrency: string
|
||||||
|
displayCurrency: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const m2mTypeDefs = `#graphql
|
||||||
|
input LockCurrencyPriceInput {
|
||||||
|
sourceAmount: Float!
|
||||||
|
sourceCurrency: String!
|
||||||
|
clientCurrency: String!
|
||||||
|
pricingCountryCode: String!
|
||||||
|
rateDate: String
|
||||||
|
}
|
||||||
|
|
||||||
|
input ConvertCurrencyForDisplayInput {
|
||||||
|
amount: Float!
|
||||||
|
sourceCurrency: String!
|
||||||
|
displayCurrency: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type DisplayCurrencyConversion {
|
||||||
|
sourceAmount: Float!
|
||||||
|
sourceCurrency: String!
|
||||||
|
displayAmount: Float!
|
||||||
|
displayCurrency: String!
|
||||||
|
exchangeRate: Float!
|
||||||
|
rateSnapshotUuid: String
|
||||||
|
rateEffectiveDate: String!
|
||||||
|
rateQuotedAt: String!
|
||||||
|
provider: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type LockedCurrencyPrice {
|
||||||
|
sourceAmount: Float!
|
||||||
|
sourceCurrency: String!
|
||||||
|
clientAmount: Float!
|
||||||
|
clientCurrency: String!
|
||||||
|
exchangeRate: Float!
|
||||||
|
rateSnapshotUuid: String
|
||||||
|
rateEffectiveDate: String!
|
||||||
|
rateQuotedAt: String!
|
||||||
|
rateLockedAt: String!
|
||||||
|
provider: String!
|
||||||
|
attributionUrl: String
|
||||||
|
pricingCountryCode: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
health: String!
|
||||||
|
convertCurrencyForDisplay(input: ConvertCurrencyForDisplayInput!): DisplayCurrencyConversion!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mutation {
|
||||||
|
lockCurrencyPrice(input: LockCurrencyPriceInput!): LockedCurrencyPrice!
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
function countryCode(value: unknown): string {
|
||||||
|
const normalized = String(value ?? '').trim().toUpperCase()
|
||||||
|
if (!/^[A-Z]{2}$/.test(normalized)) {
|
||||||
|
throw new GraphQLError(`Invalid ISO 3166-1 country code: ${normalized || 'empty'}`)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function historicalDate(value: string): Date {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||||
|
throw new GraphQLError('rateDate must use YYYY-MM-DD format')
|
||||||
|
}
|
||||||
|
const result = new Date(`${value}T00:00:00.000Z`)
|
||||||
|
if (Number.isNaN(result.getTime()) || result.toISOString().slice(0, 10) !== value) {
|
||||||
|
throw new GraphQLError('rateDate is not a valid calendar date')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lockCurrencyPrice(
|
||||||
|
_: unknown,
|
||||||
|
args: { input: LockCurrencyPriceInput },
|
||||||
|
) {
|
||||||
|
const lockedAt = new Date()
|
||||||
|
const sourceAmount = Number(args.input.sourceAmount)
|
||||||
|
if (!Number.isFinite(sourceAmount) || sourceAmount < 0) {
|
||||||
|
throw new GraphQLError('sourceAmount must be a non-negative finite number')
|
||||||
|
}
|
||||||
|
const sourceCurrency = currencyCode(args.input.sourceCurrency)
|
||||||
|
const clientCurrency = currencyCode(args.input.clientCurrency)
|
||||||
|
const pricingCountryCode = countryCode(args.input.pricingCountryCode)
|
||||||
|
if (sourceCurrency === clientCurrency) {
|
||||||
|
const effectiveDate = args.input.rateDate
|
||||||
|
? historicalDate(args.input.rateDate)
|
||||||
|
: lockedAt
|
||||||
|
return {
|
||||||
|
sourceAmount,
|
||||||
|
sourceCurrency,
|
||||||
|
clientAmount: sourceAmount,
|
||||||
|
clientCurrency,
|
||||||
|
exchangeRate: 1,
|
||||||
|
rateSnapshotUuid: null,
|
||||||
|
rateEffectiveDate: effectiveDate.toISOString().slice(0, 10),
|
||||||
|
rateQuotedAt: effectiveDate.toISOString(),
|
||||||
|
rateLockedAt: lockedAt.toISOString(),
|
||||||
|
provider: 'identity',
|
||||||
|
attributionUrl: null,
|
||||||
|
pricingCountryCode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const snapshot = args.input.rateDate
|
||||||
|
? await currencyRatesSnapshotForDate(historicalDate(args.input.rateDate))
|
||||||
|
: await latestCurrencyRatesSnapshot()
|
||||||
|
const exchangeRate = convertMoney(1, sourceCurrency, clientCurrency, snapshot)
|
||||||
|
return {
|
||||||
|
sourceAmount,
|
||||||
|
sourceCurrency,
|
||||||
|
clientAmount: sourceAmount * exchangeRate,
|
||||||
|
clientCurrency,
|
||||||
|
exchangeRate,
|
||||||
|
rateSnapshotUuid: snapshot.uuid,
|
||||||
|
rateEffectiveDate: snapshot.effectiveDate.toISOString().slice(0, 10),
|
||||||
|
rateQuotedAt: snapshot.updatedAt.toISOString(),
|
||||||
|
rateLockedAt: lockedAt.toISOString(),
|
||||||
|
provider: snapshot.provider,
|
||||||
|
attributionUrl: snapshot.attributionUrl,
|
||||||
|
pricingCountryCode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertCurrencyForDisplay(
|
||||||
|
_: unknown,
|
||||||
|
args: { input: ConvertCurrencyForDisplayInput },
|
||||||
|
) {
|
||||||
|
const sourceAmount = Number(args.input.amount)
|
||||||
|
if (!Number.isFinite(sourceAmount) || sourceAmount < 0) {
|
||||||
|
throw new GraphQLError('amount must be a non-negative finite number')
|
||||||
|
}
|
||||||
|
const sourceCurrency = currencyCode(args.input.sourceCurrency)
|
||||||
|
const displayCurrency = currencyCode(args.input.displayCurrency)
|
||||||
|
if (sourceCurrency === displayCurrency) {
|
||||||
|
const quotedAt = new Date()
|
||||||
|
return {
|
||||||
|
sourceAmount,
|
||||||
|
sourceCurrency,
|
||||||
|
displayAmount: sourceAmount,
|
||||||
|
displayCurrency,
|
||||||
|
exchangeRate: 1,
|
||||||
|
rateSnapshotUuid: null,
|
||||||
|
rateEffectiveDate: quotedAt.toISOString().slice(0, 10),
|
||||||
|
rateQuotedAt: quotedAt.toISOString(),
|
||||||
|
provider: 'identity',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const snapshot = await latestCurrencyRatesSnapshot()
|
||||||
|
const exchangeRate = convertMoney(1, sourceCurrency, displayCurrency, snapshot)
|
||||||
|
return {
|
||||||
|
sourceAmount,
|
||||||
|
sourceCurrency,
|
||||||
|
displayAmount: sourceAmount * exchangeRate,
|
||||||
|
displayCurrency,
|
||||||
|
exchangeRate,
|
||||||
|
rateSnapshotUuid: snapshot.uuid,
|
||||||
|
rateEffectiveDate: snapshot.effectiveDate.toISOString().slice(0, 10),
|
||||||
|
rateQuotedAt: snapshot.updatedAt.toISOString(),
|
||||||
|
provider: snapshot.provider,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const m2mResolvers = {
|
||||||
|
Query: {
|
||||||
|
health: () => 'ok',
|
||||||
|
convertCurrencyForDisplay,
|
||||||
|
},
|
||||||
|
Mutation: {
|
||||||
|
lockCurrencyPrice,
|
||||||
|
},
|
||||||
|
}
|
||||||
+12
-3
@@ -1,5 +1,6 @@
|
|||||||
import { prisma } from './db.js'
|
import { prisma } from './db.js'
|
||||||
import { convertPrices, displayCurrency } from './currency-rates.js'
|
import { convertPrices, displayCurrency } from './currency-rates.js'
|
||||||
|
import { latestCurrencyRatesSnapshot } from './currency-rate-store.js'
|
||||||
|
|
||||||
type SupplierInput = {
|
type SupplierInput = {
|
||||||
teamUuid?: string | null
|
teamUuid?: string | null
|
||||||
@@ -37,6 +38,14 @@ type QuoteInput = {
|
|||||||
notes?: string | null
|
notes?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function convertCatalogPrices<T extends { pricePerUnit: unknown; currency: string }>(
|
||||||
|
rows: T[],
|
||||||
|
displayCurrencyValue: unknown,
|
||||||
|
) {
|
||||||
|
const snapshot = await latestCurrencyRatesSnapshot()
|
||||||
|
return convertPrices(rows, displayCurrencyValue, snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
export const typeDefs = `#graphql
|
export const typeDefs = `#graphql
|
||||||
type Supplier {
|
type Supplier {
|
||||||
uuid: ID!
|
uuid: ID!
|
||||||
@@ -200,7 +209,7 @@ async function productWithPriceSummaryFromProduct(
|
|||||||
select: { pricePerUnit: true, currency: true },
|
select: { pricePerUnit: true, currency: true },
|
||||||
take: 100,
|
take: 100,
|
||||||
})
|
})
|
||||||
const convertedQuotes = await convertPrices(quotes, displayCurrencyValue)
|
const convertedQuotes = await convertCatalogPrices(quotes, displayCurrencyValue)
|
||||||
const priceSeries = convertedQuotes.map(quote => quote.pricePerUnit).sort((a, b) => a - b)
|
const priceSeries = convertedQuotes.map(quote => quote.pricePerUnit).sort((a, b) => a - b)
|
||||||
const averagePrice = priceSeries.length === 0
|
const averagePrice = priceSeries.length === 0
|
||||||
? null
|
? null
|
||||||
@@ -366,7 +375,7 @@ export const resolvers = {
|
|||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
items: await convertPrices(quotes.slice(0, args.limit), rawArgs.displayCurrency),
|
items: await convertCatalogPrices(quotes.slice(0, args.limit), rawArgs.displayCurrency),
|
||||||
offset: args.offset,
|
offset: args.offset,
|
||||||
limit: args.limit,
|
limit: args.limit,
|
||||||
hasMore: quotes.length > args.limit,
|
hasMore: quotes.length > args.limit,
|
||||||
@@ -379,7 +388,7 @@ export const resolvers = {
|
|||||||
include: { supplier: true, product: true },
|
include: { supplier: true, product: true },
|
||||||
})
|
})
|
||||||
if (quote === null) return null
|
if (quote === null) return null
|
||||||
return (await convertPrices([quote], args.displayCurrency))[0]
|
return (await convertCatalogPrices([quote], args.displayCurrency))[0]
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user