Compare commits
3
Commits
bd06ee04c0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d48cec9abf | ||
|
|
fe883afde2 | ||
|
|
ca0e70d723 |
+1
-1
Submodule graphql-contracts updated: 09de97f9fa...d8f6796b68
@@ -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";
|
||||||
@@ -99,12 +99,3 @@ model CurrencyRate {
|
|||||||
@@index([quoteCurrency])
|
@@index([quoteCurrency])
|
||||||
@@map("exchange_currency_rates")
|
@@map("exchange_currency_rates")
|
||||||
}
|
}
|
||||||
|
|
||||||
model PricingCurrencyPolicy {
|
|
||||||
countryCode String @id @map("country_code") @db.VarChar(2)
|
|
||||||
currencyCode String @map("currency_code") @db.VarChar(3)
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
|
|
||||||
@@index([currencyCode])
|
|
||||||
@@map("exchange_pricing_currency_policies")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
+73
-31
@@ -10,18 +10,44 @@ import { prisma } from './db.js'
|
|||||||
type LockCurrencyPriceInput = {
|
type LockCurrencyPriceInput = {
|
||||||
sourceAmount: number
|
sourceAmount: number
|
||||||
sourceCurrency: string
|
sourceCurrency: string
|
||||||
|
clientCurrency: string
|
||||||
pricingCountryCode: string
|
pricingCountryCode: string
|
||||||
rateDate?: string | null
|
rateDate?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConvertCurrencyForDisplayInput = {
|
||||||
|
amount: number
|
||||||
|
sourceCurrency: string
|
||||||
|
displayCurrency: string
|
||||||
|
}
|
||||||
|
|
||||||
export const m2mTypeDefs = `#graphql
|
export const m2mTypeDefs = `#graphql
|
||||||
input LockCurrencyPriceInput {
|
input LockCurrencyPriceInput {
|
||||||
sourceAmount: Float!
|
sourceAmount: Float!
|
||||||
sourceCurrency: String!
|
sourceCurrency: String!
|
||||||
|
clientCurrency: String!
|
||||||
pricingCountryCode: String!
|
pricingCountryCode: String!
|
||||||
rateDate: 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 {
|
type LockedCurrencyPrice {
|
||||||
sourceAmount: Float!
|
sourceAmount: Float!
|
||||||
sourceCurrency: String!
|
sourceCurrency: String!
|
||||||
@@ -37,13 +63,9 @@ export const m2mTypeDefs = `#graphql
|
|||||||
pricingCountryCode: String!
|
pricingCountryCode: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
type PricingCurrencyPolicy {
|
|
||||||
countryCode: String!
|
|
||||||
currencyCode: String!
|
|
||||||
}
|
|
||||||
|
|
||||||
type Query {
|
type Query {
|
||||||
pricingCurrencyForCountry(countryCode: String!): PricingCurrencyPolicy
|
health: String!
|
||||||
|
convertCurrencyForDisplay(input: ConvertCurrencyForDisplayInput!): DisplayCurrencyConversion!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
@@ -70,18 +92,6 @@ function historicalDate(value: string): Date {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pricingCurrencyForCountryValue(rawCountryCode: unknown) {
|
|
||||||
const normalizedCountryCode = countryCode(rawCountryCode)
|
|
||||||
const policy = await prisma.pricingCurrencyPolicy.findUnique({
|
|
||||||
where: { countryCode: normalizedCountryCode },
|
|
||||||
})
|
|
||||||
if (policy === null) return null
|
|
||||||
return {
|
|
||||||
countryCode: policy.countryCode,
|
|
||||||
currencyCode: policy.currencyCode,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function lockCurrencyPrice(
|
async function lockCurrencyPrice(
|
||||||
_: unknown,
|
_: unknown,
|
||||||
args: { input: LockCurrencyPriceInput },
|
args: { input: LockCurrencyPriceInput },
|
||||||
@@ -92,13 +102,8 @@ async function lockCurrencyPrice(
|
|||||||
throw new GraphQLError('sourceAmount must be a non-negative finite number')
|
throw new GraphQLError('sourceAmount must be a non-negative finite number')
|
||||||
}
|
}
|
||||||
const sourceCurrency = currencyCode(args.input.sourceCurrency)
|
const sourceCurrency = currencyCode(args.input.sourceCurrency)
|
||||||
const policy = await pricingCurrencyForCountryValue(args.input.pricingCountryCode)
|
const clientCurrency = currencyCode(args.input.clientCurrency)
|
||||||
if (policy === null) {
|
const pricingCountryCode = countryCode(args.input.pricingCountryCode)
|
||||||
throw new GraphQLError(
|
|
||||||
`Pricing currency policy is missing for country ${countryCode(args.input.pricingCountryCode)}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const clientCurrency = currencyCode(policy.currencyCode)
|
|
||||||
if (sourceCurrency === clientCurrency) {
|
if (sourceCurrency === clientCurrency) {
|
||||||
const effectiveDate = args.input.rateDate
|
const effectiveDate = args.input.rateDate
|
||||||
? historicalDate(args.input.rateDate)
|
? historicalDate(args.input.rateDate)
|
||||||
@@ -115,7 +120,7 @@ async function lockCurrencyPrice(
|
|||||||
rateLockedAt: lockedAt.toISOString(),
|
rateLockedAt: lockedAt.toISOString(),
|
||||||
provider: 'identity',
|
provider: 'identity',
|
||||||
attributionUrl: null,
|
attributionUrl: null,
|
||||||
pricingCountryCode: policy.countryCode,
|
pricingCountryCode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const snapshot = args.input.rateDate
|
const snapshot = args.input.rateDate
|
||||||
@@ -134,16 +139,53 @@ async function lockCurrencyPrice(
|
|||||||
rateLockedAt: lockedAt.toISOString(),
|
rateLockedAt: lockedAt.toISOString(),
|
||||||
provider: snapshot.provider,
|
provider: snapshot.provider,
|
||||||
attributionUrl: snapshot.attributionUrl,
|
attributionUrl: snapshot.attributionUrl,
|
||||||
pricingCountryCode: policy.countryCode,
|
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 = {
|
export const m2mResolvers = {
|
||||||
Query: {
|
Query: {
|
||||||
pricingCurrencyForCountry: (
|
health: () => 'ok',
|
||||||
_: unknown,
|
convertCurrencyForDisplay,
|
||||||
args: { countryCode: string },
|
|
||||||
) => pricingCurrencyForCountryValue(args.countryCode),
|
|
||||||
},
|
},
|
||||||
Mutation: {
|
Mutation: {
|
||||||
lockCurrencyPrice,
|
lockCurrencyPrice,
|
||||||
|
|||||||
Reference in New Issue
Block a user