Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions __tests__/lib/fx/quote-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest"

import { StaticExchangeRateAdapter, TimeoutExchangeRateAdapter, type ExchangeRateProviderAdapter } from "@/lib/fx/adapters"
import { ExchangeRateQuoteService, InMemoryQuoteRepository } from "@/lib/fx/quote-service"
import { convertMajorAmount } from "@/lib/fx/types"

function createService(adapters: ExchangeRateProviderAdapter[] = [new StaticExchangeRateAdapter({ "USD/NGN": 1500 })]) {
return new ExchangeRateQuoteService(adapters, new InMemoryQuoteRepository(), {
maxQuoteAgeMs: 60_000,
quoteTtlMs: 60_000,
deviationThresholdBps: 250,
markupBps: 0,
supportedPairs: ["USD/NGN", "NGN/USD"],
})
}

describe("ExchangeRateQuoteService", () => {
it("creates and consumes fresh quotes once", async () => {
const now = new Date("2026-01-01T00:00:00.000Z")
const service = createService()
const quote = await service.createQuote({
baseCurrency: "USD",
quoteCurrency: "NGN",
sourceAmountMajor: 10,
now,
})

expect(quote.convertedAmountMinor).toBe(1_500_000)

const consumed = await service.consumeQuote({
quoteId: quote.id,
baseCurrency: "USD",
quoteCurrency: "NGN",
sourceAmountMajor: 10,
consumedBy: "txn_1",
now,
})

expect(consumed.status).toBe("consumed")
await expect(
service.consumeQuote({
quoteId: quote.id,
baseCurrency: "USD",
quoteCurrency: "NGN",
sourceAmountMajor: 10,
consumedBy: "txn_2",
now,
}),
).rejects.toThrow("already been consumed")
})

it("rejects expired quotes", async () => {
const service = createService()
const quote = await service.createQuote({
baseCurrency: "USD",
quoteCurrency: "NGN",
sourceAmountMajor: 1,
now: new Date("2026-01-01T00:00:00.000Z"),
})

await expect(
service.consumeQuote({
quoteId: quote.id,
baseCurrency: "USD",
quoteCurrency: "NGN",
sourceAmountMajor: 1,
consumedBy: "txn_1",
now: new Date("2026-01-01T00:02:00.000Z"),
}),
).rejects.toThrow("expired")
})

it("supports inverse pairs through the static adapter", async () => {
const service = createService([new StaticExchangeRateAdapter({ "USD/NGN": 1500 })])
const quote = await service.createQuote({
baseCurrency: "NGN",
quoteCurrency: "USD",
sourceAmountMajor: 1500,
})

expect(quote.convertedAmountMinor).toBe(100)
})

it("falls back after provider timeout", async () => {
const service = createService([
new TimeoutExchangeRateAdapter(),
new StaticExchangeRateAdapter({ "USD/NGN": 1500 }, "fallback"),
])

const quote = await service.createQuote({
baseCurrency: "USD",
quoteCurrency: "NGN",
sourceAmountMajor: 2,
})

expect(quote.provider).toBe("fallback")
expect(quote.convertedAmountMajor).toBe(3000)
})

it("deduplicates idempotency keys", async () => {
const service = createService()
const first = await service.createQuote({
baseCurrency: "USD",
quoteCurrency: "NGN",
sourceAmountMajor: 5,
idempotencyKey: "idem-1",
})
const second = await service.createQuote({
baseCurrency: "USD",
quoteCurrency: "NGN",
sourceAmountMajor: 5,
idempotencyKey: "idem-1",
})

expect(second.id).toBe(first.id)
})

it("uses deterministic minor-unit rounding and rejects invalid rates", () => {
expect(
convertMajorAmount({
amountMajor: 1.005,
rate: 1500,
sourceCurrency: "USD",
targetCurrency: "NGN",
}).convertedAmountMinor,
).toBe(150_750)

expect(() =>
convertMajorAmount({
amountMajor: 1,
rate: Number.NaN,
sourceCurrency: "USD",
targetCurrency: "NGN",
}),
).toThrow("positive and finite")
})
})
17 changes: 17 additions & 0 deletions docs/exchange-rate-integrity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Exchange-Rate Integrity

All conversion logic must use `ExchangeRateQuoteService`. Clients may submit source amounts and currencies, but never rates. Balance-changing routes reject unexpected fields so raw client rates do not enter booked transactions.

Quote lifecycle:

- A quote snapshot records pair, direction, amount policy, provider rate, marked-up rate, provider timestamp, fetched time, expiry, provider name, version, and deterministic major/minor-unit conversion.
- A quote can be locked before work starts and consumed once during booking.
- Consumed quotes are immutable except for consumption metadata and are linked from transactions through `exchangeRateQuoteId` plus `bookedQuoteSnapshot`.
- Historical reports and reconciliation must use the booked snapshot. Current rates are only indicative.
- Unsupported, zero, negative, stale, or non-finite rates are rejected.

Operations:

- `npm run fx:legacy-check` inventories legacy transaction rows with `originalCurrency`, `exchangeRate`, or `amountOriginal`.
- Contributors can run offline with the static adapter and `FX_STATIC_RATES_JSON`.
- Provider fallback must not accept materially deviating rates beyond `FX_DEVIATION_BPS`.
82 changes: 82 additions & 0 deletions lib/fx/adapters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { CurrencyCode, isValidRate } from "@/lib/fx/types"

export type ProviderQuote = {
baseCurrency: CurrencyCode
quoteCurrency: CurrencyCode
rate: number
provider: string
providerTimestamp: Date
}

export interface ExchangeRateProviderAdapter {
readonly name: string
getRate(baseCurrency: CurrencyCode, quoteCurrency: CurrencyCode): Promise<ProviderQuote>
}

export class StaticExchangeRateAdapter implements ExchangeRateProviderAdapter {
readonly name: string

constructor(
private readonly rates: Record<string, number>,
name = "static",
) {
this.name = name
}

async getRate(baseCurrency: CurrencyCode, quoteCurrency: CurrencyCode) {
const directKey = `${baseCurrency}/${quoteCurrency}`
const inverseKey = `${quoteCurrency}/${baseCurrency}`
const directRate = this.rates[directKey]
const inverseRate = this.rates[inverseKey]

if (isValidRate(directRate)) {
return {
baseCurrency,
quoteCurrency,
rate: directRate,
provider: this.name,
providerTimestamp: new Date(),
}
}

if (isValidRate(inverseRate)) {
return {
baseCurrency,
quoteCurrency,
rate: 1 / inverseRate,
provider: this.name,
providerTimestamp: new Date(),
}
}

throw new Error(`Unsupported FX pair ${baseCurrency}/${quoteCurrency}.`)
}
}

export class TimeoutExchangeRateAdapter implements ExchangeRateProviderAdapter {
constructor(readonly name = "timeout") {}

async getRate(): Promise<ProviderQuote> {
throw new Error("FX provider timed out.")
}
}

export function parseStaticRates(raw?: string) {
if (!raw) {
return {
"USD/NGN": 1500,
"EUR/NGN": 1650,
"GBP/NGN": 1900,
"NGN/NGN": 1,
}
}

const parsed = JSON.parse(raw) as Record<string, number>
for (const [pair, rate] of Object.entries(parsed)) {
if (!/^[A-Z]{3}\/[A-Z]{3}$/.test(pair) || !isValidRate(rate)) {
throw new Error(`Invalid static FX rate for ${pair}.`)
}
}

return parsed
}
82 changes: 82 additions & 0 deletions lib/fx/mongoose-quote-repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import ExchangeRateQuote from "@/models/ExchangeRateQuote"
import { ExchangeRateQuoteSnapshot } from "@/lib/fx/types"
import { QuoteRepository } from "@/lib/fx/quote-service"

function toSnapshot(document: any): ExchangeRateQuoteSnapshot {
return {
id: document._id.toString(),
version: document.version,
baseCurrency: document.baseCurrency,
quoteCurrency: document.quoteCurrency,
direction: document.direction,
sourceAmountMajor: document.sourceAmountMajor,
sourceAmountMinor: document.sourceAmountMinor,
convertedAmountMajor: document.convertedAmountMajor,
convertedAmountMinor: document.convertedAmountMinor,
rate: document.rate,
providerRate: document.providerRate,
provider: document.provider,
providerTimestamp: document.providerTimestamp,
fetchedAt: document.fetchedAt,
expiresAt: document.expiresAt,
markupBps: document.markupBps,
spreadBps: document.spreadBps,
amountPolicy: document.amountPolicy,
status: document.status,
idempotencyKey: document.idempotencyKey,
consumedAt: document.consumedAt,
consumedBy: document.consumedBy,
}
}

export class MongooseQuoteRepository implements QuoteRepository {
async create(snapshot: ExchangeRateQuoteSnapshot) {
const document = await ExchangeRateQuote.create({
version: snapshot.version,
baseCurrency: snapshot.baseCurrency,
quoteCurrency: snapshot.quoteCurrency,
direction: snapshot.direction,
sourceAmountMajor: snapshot.sourceAmountMajor,
sourceAmountMinor: snapshot.sourceAmountMinor,
convertedAmountMajor: snapshot.convertedAmountMajor,
convertedAmountMinor: snapshot.convertedAmountMinor,
rate: snapshot.rate,
providerRate: snapshot.providerRate,
provider: snapshot.provider,
providerTimestamp: snapshot.providerTimestamp,
fetchedAt: snapshot.fetchedAt,
expiresAt: snapshot.expiresAt,
markupBps: snapshot.markupBps,
spreadBps: snapshot.spreadBps,
amountPolicy: snapshot.amountPolicy,
status: snapshot.status,
idempotencyKey: snapshot.idempotencyKey,
})
return toSnapshot(document)
}

async findById(id: string) {
const document = await ExchangeRateQuote.findById(id)
return document ? toSnapshot(document) : null
}

async findByIdempotencyKey(key: string) {
const document = await ExchangeRateQuote.findOne({ idempotencyKey: key })
return document ? toSnapshot(document) : null
}

async update(snapshot: ExchangeRateQuoteSnapshot) {
const document = await ExchangeRateQuote.findByIdAndUpdate(
snapshot.id,
{
status: snapshot.status,
consumedAt: snapshot.consumedAt,
consumedBy: snapshot.consumedBy,
},
{ new: true, runValidators: true },
)

if (!document) throw new Error("Quote not found.")
return toSnapshot(document)
}
}
Loading
Loading