From 742883c95092f04dcc6397c903691aef4a60c2ee Mon Sep 17 00:00:00 2001 From: Big Della Date: Sat, 18 Jul 2026 09:44:58 +0000 Subject: [PATCH] Add versioned FX quote integrity service --- .env.example | 8 + __tests__/lib/fx/quote-service.test.ts | 137 +++++++ app/api/payments/initialize/route.ts | 1 + app/api/pools/[poolId]/invest/route.ts | 31 +- app/api/users/[id]/route.ts | 25 +- docs/exchange-rate-integrity.md | 17 + lib/fx/adapters.ts | 82 ++++ lib/fx/mongoose-quote-repository.ts | 82 ++++ lib/fx/quote-service.ts | 233 +++++++++++ lib/fx/types.ts | 93 +++++ lib/stellar/config.ts | 2 + models/ExchangeRateQuote.ts | 66 ++++ models/StellarPoolAsset.ts | 2 +- models/Transaction.ts | 22 +- package-lock.json | 504 ++++++++++++++++++++++++ package.json | 2 + scripts/check-legacy-fx-transactions.ts | 49 +++ 17 files changed, 1325 insertions(+), 31 deletions(-) create mode 100644 __tests__/lib/fx/quote-service.test.ts create mode 100644 docs/exchange-rate-integrity.md create mode 100644 lib/fx/adapters.ts create mode 100644 lib/fx/mongoose-quote-repository.ts create mode 100644 lib/fx/quote-service.ts create mode 100644 lib/fx/types.ts create mode 100644 models/ExchangeRateQuote.ts create mode 100644 scripts/check-legacy-fx-transactions.ts diff --git a/.env.example b/.env.example index 9b2f6577..49335b87 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,14 @@ ENABLE_MOCK_PAYMENTS=true ENABLE_MOCK_EMAILS=true ENABLE_MOCK_STELLAR=true +# Exchange rates / conversion integrity +FX_PROVIDER=static +FX_STATIC_RATES_JSON={"USD/NGN":1500,"EUR/NGN":1650,"GBP/NGN":1900,"NGN/NGN":1} +FX_MAX_QUOTE_AGE_SECONDS=900 +FX_QUOTE_TTL_SECONDS=900 +FX_DEVIATION_BPS=250 +FX_MARKUP_BPS=0 + # Maintainer-only production secrets intentionally omitted: # SECRET_KEY_HEX, TREASURY_PK_KEY, THIRDWEB_SECRET_KEY, real PAYSTACK_SECRET_KEY, # real PRIVY_APP_SECRET, real RESEND_API_KEY, production MongoDB URI, and production signer keys. diff --git a/__tests__/lib/fx/quote-service.test.ts b/__tests__/lib/fx/quote-service.test.ts new file mode 100644 index 00000000..9a8fb994 --- /dev/null +++ b/__tests__/lib/fx/quote-service.test.ts @@ -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") + }) +}) diff --git a/app/api/payments/initialize/route.ts b/app/api/payments/initialize/route.ts index 831a86b5..148ddb75 100644 --- a/app/api/payments/initialize/route.ts +++ b/app/api/payments/initialize/route.ts @@ -22,6 +22,7 @@ const bodySchema = z ), email: z.string().trim().email().max(254).optional(), }) + .strict() .refine((value) => typeof value.amount === "number" || typeof value.amountNgn === "number", { message: "A valid amount is required.", path: ["amountNgn"], diff --git a/app/api/pools/[poolId]/invest/route.ts b/app/api/pools/[poolId]/invest/route.ts index f2e1642f..4f88b994 100644 --- a/app/api/pools/[poolId]/invest/route.ts +++ b/app/api/pools/[poolId]/invest/route.ts @@ -1,8 +1,17 @@ -import { NextResponse } from "next/server" - +import { NextResponse } from "next/server" +import { z } from "zod" + import { getAuthenticatedUser, withSessionRefresh } from "@/lib/auth/current-user" +import { parseJsonBody } from "@/lib/api/validation" import { investInPool } from "@/lib/services/investments.service" +const investmentRequestSchema = z + .object({ + amountNgn: z.preprocess((value) => Number(value), z.number().positive().max(100_000_000)), + txRef: z.string().trim().max(128).optional(), + }) + .strict() + function isTransientTransactionError(error: unknown) { if (!error || typeof error !== "object") return false @@ -35,16 +44,18 @@ export async function POST(request: Request, context: { params: Promise<{ poolId return NextResponse.json({ message: "Only investors or admins can invest in pools." }, { status: 403 }) } - const body = await request.json() - const amountNgn = Number(body.amountNgn) - const { poolId } = await context.params + const body = await parseJsonBody(request, investmentRequestSchema) + if ("response" in body) return body.response + + const amountNgn = body.data.amountNgn + const { poolId } = await context.params const investment = await investInPool({ - poolId, - userId: user._id.toString(), - amountNgn, - txRef: typeof body.txRef === "string" ? body.txRef : undefined, - }) + poolId, + userId: user._id.toString(), + amountNgn, + txRef: body.data.txRef, + }) const response = NextResponse.json({ success: true, investment }, { status: 201 }) return shouldRefreshSession ? withSessionRefresh(response, user) : response diff --git a/app/api/users/[id]/route.ts b/app/api/users/[id]/route.ts index e55e815c..3895e28e 100644 --- a/app/api/users/[id]/route.ts +++ b/app/api/users/[id]/route.ts @@ -7,7 +7,7 @@ import { getClientIpAddress } from "@/lib/security/rate-limit" import { validatePhoneNumberInput } from "@/lib/validation/phone" import User from "@/models/User" -type RouteContext = { params: { id: string } } +type RouteContext = { params: Promise<{ id: string }> } type UserRole = "admin" | "driver" | "investor" const VALID_ROLES: UserRole[] = ["admin", "driver", "investor"] @@ -88,12 +88,13 @@ function resolveDuplicateKeyMessage(error: unknown) { export async function GET(request: Request, { params }: RouteContext) { try { + const { id } = await params const auth = await requireAdmin(request) if ("error" in auth) return auth.error await dbConnect() - const user = await User.findById(params.id).select( + const user = await User.findById(id).select( "name fullName email phoneNumber role walletAddress walletaddress privyUserId availableBalance totalInvested totalReturns createdAt", ) @@ -117,7 +118,8 @@ export async function GET(request: Request, { params }: RouteContext) { export async function PUT(request: Request, { params }: RouteContext) { try { - const auth = await requireUserUpdateAccess(request, params.id) + const { id } = await params + const auth = await requireUserUpdateAccess(request, id) if ("error" in auth) return auth.error await dbConnect() @@ -147,11 +149,11 @@ export async function PUT(request: Request, { params }: RouteContext) { return NextResponse.json({ message: "No user changes were provided." }, { status: 400 }) } - if (params.id === auth.user!._id.toString() && hasRole && role !== "admin") { + if (id === auth.user!._id.toString() && hasRole && role !== "admin") { return NextResponse.json({ message: "You cannot remove your own admin access." }, { status: 403 }) } - const existingUser = await User.findById(params.id).select( + const existingUser = await User.findById(id).select( "name fullName email phoneNumber role walletAddress walletaddress privyUserId", ) if (!existingUser) { @@ -247,7 +249,7 @@ export async function PUT(request: Request, { params }: RouteContext) { actor: auth.user, action: auth.isSelf ? "user.self_update" : "user.update", targetType: "user", - targetId: params.id, + targetId: id, ipAddress: getClientIpAddress(request), metadata: { changedFields, @@ -255,7 +257,7 @@ export async function PUT(request: Request, { params }: RouteContext) { }, }) - const updatedUser = await User.findById(params.id) + const updatedUser = await User.findById(id) .select("name fullName email phoneNumber role privyUserId walletAddress walletaddress createdAt") .lean() @@ -289,16 +291,17 @@ export async function PUT(request: Request, { params }: RouteContext) { export async function DELETE(request: Request, { params }: RouteContext) { try { + const { id } = await params const auth = await requireAdmin(request) if ("error" in auth) return auth.error await dbConnect() - if (params.id === auth.user!._id.toString()) { + if (id === auth.user!._id.toString()) { return NextResponse.json({ message: "You cannot delete your own account." }, { status: 403 }) } - const existingUser = await User.findById(params.id).select("role") + const existingUser = await User.findById(id).select("role") if (!existingUser) { return NextResponse.json({ message: "User not found" }, { status: 404 }) } @@ -310,13 +313,13 @@ export async function DELETE(request: Request, { params }: RouteContext) { } } - await User.findByIdAndDelete(params.id) + await User.findByIdAndDelete(id) await logAuditEvent({ actor: auth.user, action: "user.delete", targetType: "user", - targetId: params.id, + targetId: id, ipAddress: getClientIpAddress(request), metadata: { deletedRole: existingUser.role, diff --git a/docs/exchange-rate-integrity.md b/docs/exchange-rate-integrity.md new file mode 100644 index 00000000..b94da7d9 --- /dev/null +++ b/docs/exchange-rate-integrity.md @@ -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`. diff --git a/lib/fx/adapters.ts b/lib/fx/adapters.ts new file mode 100644 index 00000000..c315675c --- /dev/null +++ b/lib/fx/adapters.ts @@ -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 +} + +export class StaticExchangeRateAdapter implements ExchangeRateProviderAdapter { + readonly name: string + + constructor( + private readonly rates: Record, + 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 { + 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 + 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 +} diff --git a/lib/fx/mongoose-quote-repository.ts b/lib/fx/mongoose-quote-repository.ts new file mode 100644 index 00000000..cfc7268d --- /dev/null +++ b/lib/fx/mongoose-quote-repository.ts @@ -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) + } +} diff --git a/lib/fx/quote-service.ts b/lib/fx/quote-service.ts new file mode 100644 index 00000000..ce79f435 --- /dev/null +++ b/lib/fx/quote-service.ts @@ -0,0 +1,233 @@ +import { ExchangeRateProviderAdapter } from "@/lib/fx/adapters" +import { + AmountPolicy, + CurrencyCode, + ExchangeRateQuoteSnapshot, + QuoteDirection, + assertCurrency, + convertMajorAmount, + isValidRate, +} from "@/lib/fx/types" + +export type QuoteServiceConfig = { + maxQuoteAgeMs: number + quoteTtlMs: number + deviationThresholdBps: number + markupBps: number + supportedPairs: readonly string[] +} + +export interface QuoteRepository { + create(snapshot: ExchangeRateQuoteSnapshot): Promise + findById(id: string): Promise + findByIdempotencyKey(key: string): Promise + update(snapshot: ExchangeRateQuoteSnapshot): Promise +} + +export class InMemoryQuoteRepository implements QuoteRepository { + private readonly quotes = new Map() + + async create(snapshot: ExchangeRateQuoteSnapshot) { + this.quotes.set(snapshot.id, structuredClone(snapshot)) + return structuredClone(snapshot) + } + + async findById(id: string) { + const quote = this.quotes.get(id) + return quote ? structuredClone(quote) : null + } + + async findByIdempotencyKey(key: string) { + for (const quote of this.quotes.values()) { + if (quote.idempotencyKey === key) return structuredClone(quote) + } + return null + } + + async update(snapshot: ExchangeRateQuoteSnapshot) { + this.quotes.set(snapshot.id, structuredClone(snapshot)) + return structuredClone(snapshot) + } +} + +function nowMs(date: Date) { + return date.getTime() +} + +function deviationBps(a: number, b: number) { + return Math.abs(a - b) / Math.min(a, b) * 10_000 +} + +function makeId() { + return `fxq_${Date.now()}_${Math.random().toString(36).slice(2, 10)}` +} + +export class ExchangeRateQuoteService { + constructor( + private readonly providers: readonly ExchangeRateProviderAdapter[], + private readonly repository: QuoteRepository, + private readonly config: QuoteServiceConfig, + ) {} + + async createQuote(input: { + baseCurrency: string + quoteCurrency: string + sourceAmountMajor: number + direction?: QuoteDirection + amountPolicy?: AmountPolicy + idempotencyKey?: string + now?: Date + }) { + const baseCurrency = assertCurrency(input.baseCurrency) + const quoteCurrency = assertCurrency(input.quoteCurrency) + const pair = `${baseCurrency}/${quoteCurrency}` + const direction = input.direction || "direct" + const amountPolicy = input.amountPolicy || "exact-source" + const now = input.now || new Date() + + if (!this.config.supportedPairs.includes(pair)) { + throw new Error(`Unsupported FX pair ${pair}.`) + } + + if (!Number.isFinite(input.sourceAmountMajor) || input.sourceAmountMajor <= 0) { + throw new Error("Quote amount must be greater than zero.") + } + + if (input.idempotencyKey) { + const existing = await this.repository.findByIdempotencyKey(input.idempotencyKey) + if (existing) return existing + } + + const providerQuote = await this.resolveProviderQuote(baseCurrency, quoteCurrency) + const ageMs = nowMs(now) - nowMs(providerQuote.providerTimestamp) + if (ageMs > this.config.maxQuoteAgeMs) { + throw new Error("FX provider quote is stale.") + } + + const rate = providerQuote.rate * (1 + this.config.markupBps / 10_000) + if (!isValidRate(rate)) { + throw new Error("FX provider returned an invalid rate.") + } + + const amounts = convertMajorAmount({ + amountMajor: input.sourceAmountMajor, + rate, + sourceCurrency: baseCurrency, + targetCurrency: quoteCurrency, + }) + + return this.repository.create({ + id: makeId(), + version: 1, + baseCurrency, + quoteCurrency, + direction, + sourceAmountMajor: input.sourceAmountMajor, + sourceAmountMinor: amounts.sourceAmountMinor, + convertedAmountMajor: amounts.convertedAmountMajor, + convertedAmountMinor: amounts.convertedAmountMinor, + rate, + providerRate: providerQuote.rate, + provider: providerQuote.provider, + providerTimestamp: providerQuote.providerTimestamp, + fetchedAt: now, + expiresAt: new Date(now.getTime() + this.config.quoteTtlMs), + markupBps: this.config.markupBps, + spreadBps: this.config.markupBps, + amountPolicy, + status: "created", + idempotencyKey: input.idempotencyKey, + }) + } + + async lockQuote(id: string, now = new Date()) { + const quote = await this.requireQuote(id) + this.assertUsable(quote, now) + if (quote.status === "created") { + return this.repository.update({ ...quote, status: "locked" }) + } + return quote + } + + async consumeQuote(input: { + quoteId: string + baseCurrency: string + quoteCurrency: string + sourceAmountMajor: number + direction?: QuoteDirection + amountPolicy?: AmountPolicy + consumedBy: string + now?: Date + }) { + const quote = await this.requireQuote(input.quoteId) + const now = input.now || new Date() + this.assertUsable(quote, now) + + const baseCurrency = assertCurrency(input.baseCurrency) + const quoteCurrency = assertCurrency(input.quoteCurrency) + if (quote.baseCurrency !== baseCurrency || quote.quoteCurrency !== quoteCurrency) { + throw new Error("Quote pair does not match the requested conversion.") + } + + if ((input.direction || "direct") !== quote.direction) { + throw new Error("Quote direction does not match the requested conversion.") + } + + if ((input.amountPolicy || "exact-source") !== quote.amountPolicy) { + throw new Error("Quote amount policy does not match the requested conversion.") + } + + if (quote.amountPolicy === "exact-source" && quote.sourceAmountMajor !== input.sourceAmountMajor) { + throw new Error("Quote source amount does not match the requested conversion.") + } + + if (quote.status === "consumed") { + throw new Error("Quote has already been consumed.") + } + + return this.repository.update({ + ...quote, + status: "consumed", + consumedAt: now, + consumedBy: input.consumedBy, + }) + } + + private async resolveProviderQuote(baseCurrency: CurrencyCode, quoteCurrency: CurrencyCode) { + let firstRate: number | null = null + let lastError: Error | null = null + + for (const provider of this.providers) { + try { + const quote = await provider.getRate(baseCurrency, quoteCurrency) + if (!isValidRate(quote.rate)) throw new Error("FX provider returned an invalid rate.") + + if (firstRate !== null && deviationBps(firstRate, quote.rate) > this.config.deviationThresholdBps) { + throw new Error("FX fallback deviation threshold breached.") + } + + return quote + } catch (error) { + lastError = error instanceof Error ? error : new Error("FX provider failed.") + } + } + + throw lastError || new Error("No FX providers configured.") + } + + private async requireQuote(id: string) { + const quote = await this.repository.findById(id) + if (!quote) throw new Error("Quote not found.") + return quote + } + + private assertUsable(quote: ExchangeRateQuoteSnapshot, now: Date) { + if (quote.expiresAt.getTime() < now.getTime()) { + throw new Error("Quote has expired.") + } + + if (quote.status === "expired") { + throw new Error("Quote has expired.") + } + } +} diff --git a/lib/fx/types.ts b/lib/fx/types.ts new file mode 100644 index 00000000..20302f7f --- /dev/null +++ b/lib/fx/types.ts @@ -0,0 +1,93 @@ +import { z } from "zod" + +export const SUPPORTED_CURRENCIES = ["NGN", "USD", "EUR", "GBP"] as const +export type CurrencyCode = (typeof SUPPORTED_CURRENCIES)[number] + +export const CurrencyCodeSchema = z + .string() + .trim() + .transform((value) => value.toUpperCase()) + .pipe(z.enum(SUPPORTED_CURRENCIES)) + +export const MoneyMajorSchema = z.object({ + currency: CurrencyCodeSchema, + amountMajor: z.number().finite().positive(), +}) + +export type MoneyMajor = z.infer + +export type QuoteDirection = "direct" | "inverse" +export type AmountPolicy = "exact-source" | "max-source" +export type QuoteStatus = "created" | "locked" | "consumed" | "expired" + +export type ExchangeRateQuoteSnapshot = { + id: string + version: number + baseCurrency: CurrencyCode + quoteCurrency: CurrencyCode + direction: QuoteDirection + sourceAmountMajor: number + sourceAmountMinor: number + convertedAmountMajor: number + convertedAmountMinor: number + rate: number + providerRate: number + provider: string + providerTimestamp: Date + fetchedAt: Date + expiresAt: Date + markupBps: number + spreadBps: number + amountPolicy: AmountPolicy + status: QuoteStatus + idempotencyKey?: string + consumedAt?: Date + consumedBy?: string +} + +export const MINOR_UNITS: Record = { + NGN: 2, + USD: 2, + EUR: 2, + GBP: 2, +} + +export function assertCurrency(value: string): CurrencyCode { + return CurrencyCodeSchema.parse(value) +} + +export function isValidRate(rate: number) { + return Number.isFinite(rate) && rate > 0 +} + +export function toMinorUnits(amountMajor: number, currency: CurrencyCode) { + if (!Number.isFinite(amountMajor)) throw new Error("Money amount must be finite.") + const multiplier = 10 ** MINOR_UNITS[currency] + return Math.round((amountMajor + Number.EPSILON) * multiplier) +} + +export function fromMinorUnits(amountMinor: number, currency: CurrencyCode) { + const multiplier = 10 ** MINOR_UNITS[currency] + return amountMinor / multiplier +} + +export function convertMajorAmount({ + amountMajor, + rate, + sourceCurrency, + targetCurrency, +}: { + amountMajor: number + rate: number + sourceCurrency: CurrencyCode + targetCurrency: CurrencyCode +}) { + if (!isValidRate(rate)) throw new Error("Exchange rate must be positive and finite.") + const convertedMajor = amountMajor * rate + const convertedMinor = toMinorUnits(convertedMajor, targetCurrency) + return { + sourceAmountMinor: toMinorUnits(amountMajor, sourceCurrency), + convertedAmountMinor: convertedMinor, + convertedAmountMajor: fromMinorUnits(convertedMinor, targetCurrency), + } +} diff --git a/lib/stellar/config.ts b/lib/stellar/config.ts index b84862b8..c9713f9f 100644 --- a/lib/stellar/config.ts +++ b/lib/stellar/config.ts @@ -12,6 +12,8 @@ export interface StellarConfig { issuerPublicKey: string distributionPublicKey: string contractId: string + explorerBaseUrl?: string + demoPublicKey?: string mock: boolean } diff --git a/models/ExchangeRateQuote.ts b/models/ExchangeRateQuote.ts new file mode 100644 index 00000000..a371205f --- /dev/null +++ b/models/ExchangeRateQuote.ts @@ -0,0 +1,66 @@ +import mongoose, { Document, Schema } from "mongoose" + +export interface IExchangeRateQuote extends Document { + version: number + baseCurrency: string + quoteCurrency: string + direction: "direct" | "inverse" + sourceAmountMajor: number + sourceAmountMinor: number + convertedAmountMajor: number + convertedAmountMinor: number + rate: number + providerRate: number + provider: string + providerTimestamp: Date + fetchedAt: Date + expiresAt: Date + markupBps: number + spreadBps: number + amountPolicy: "exact-source" | "max-source" + status: "created" | "locked" | "consumed" | "expired" + idempotencyKey?: string + consumedAt?: Date + consumedBy?: string +} + +const ExchangeRateQuoteSchema = new Schema( + { + version: { type: Number, required: true, default: 1, immutable: true }, + baseCurrency: { type: String, required: true, immutable: true, index: true }, + quoteCurrency: { type: String, required: true, immutable: true, index: true }, + direction: { type: String, enum: ["direct", "inverse"], required: true, immutable: true }, + sourceAmountMajor: { type: Number, required: true, immutable: true }, + sourceAmountMinor: { type: Number, required: true, immutable: true }, + convertedAmountMajor: { type: Number, required: true, immutable: true }, + convertedAmountMinor: { type: Number, required: true, immutable: true }, + rate: { type: Number, required: true, immutable: true }, + providerRate: { type: Number, required: true, immutable: true }, + provider: { type: String, required: true, immutable: true }, + providerTimestamp: { type: Date, required: true, immutable: true }, + fetchedAt: { type: Date, required: true, immutable: true }, + expiresAt: { type: Date, required: true, immutable: true, index: true }, + markupBps: { type: Number, required: true, immutable: true }, + spreadBps: { type: Number, required: true, immutable: true }, + amountPolicy: { type: String, enum: ["exact-source", "max-source"], required: true, immutable: true }, + status: { type: String, enum: ["created", "locked", "consumed", "expired"], required: true, default: "created", index: true }, + idempotencyKey: { type: String, index: true, unique: true, sparse: true, immutable: true }, + consumedAt: { type: Date }, + consumedBy: { type: String }, + }, + { timestamps: true }, +) + +ExchangeRateQuoteSchema.pre("save", function validateImmutableConsumption(next) { + if (!this.isNew && this.isModified()) { + const modified = this.modifiedPaths().filter((path) => !["status", "consumedAt", "consumedBy", "updatedAt"].includes(path)) + if (modified.length > 0 && this.status === "consumed") { + next(new Error("Consumed exchange-rate quotes are immutable.")) + return + } + } + next() +}) + +export default (mongoose.models.ExchangeRateQuote || + mongoose.model("ExchangeRateQuote", ExchangeRateQuoteSchema)) as mongoose.Model diff --git a/models/StellarPoolAsset.ts b/models/StellarPoolAsset.ts index 473b37df..5fadf7a1 100644 --- a/models/StellarPoolAsset.ts +++ b/models/StellarPoolAsset.ts @@ -116,7 +116,7 @@ const StellarPoolAssetSchema: Schema = new Schema( StellarPoolAssetSchema.index({ assetCode: 1, issuerPublicKey: 1 }) StellarPoolAssetSchema.index({ status: 1, network: 1 }) -StellarPoolAssetSchema.pre("save", function (next) { +StellarPoolAssetSchema.pre("save", function (next) { if (this.isModified("assetCode")) { this.assetCode = this.assetCode.toUpperCase().trim() } diff --git a/models/Transaction.ts b/models/Transaction.ts index 66da4a7e..f055a5c7 100644 --- a/models/Transaction.ts +++ b/models/Transaction.ts @@ -16,10 +16,12 @@ export interface ITransaction extends Document { | "down_payment" amount: number amountOriginal?: number - currency?: string - originalCurrency?: string - exchangeRate?: number - method?: "wallet" | "internal_wallet" | "gateway" | "paystack" | "privy" | "system" + currency?: string + originalCurrency?: string + exchangeRate?: number + exchangeRateQuoteId?: Schema.Types.ObjectId + bookedQuoteSnapshot?: Record + method?: "wallet" | "internal_wallet" | "gateway" | "paystack" | "privy" | "system" gatewayReference?: string description: string status: "Pending" | "Completed" | "Failed" @@ -53,10 +55,12 @@ const TransactionSchema: Schema = new Schema({ }, amount: { type: Number, required: true }, amountOriginal: { type: Number }, - currency: { type: String, default: "NGN" }, - originalCurrency: { type: String }, - exchangeRate: { type: Number }, - method: { + currency: { type: String, default: "NGN" }, + originalCurrency: { type: String }, + exchangeRate: { type: Number }, + exchangeRateQuoteId: { type: Schema.Types.ObjectId, ref: "ExchangeRateQuote", index: true }, + bookedQuoteSnapshot: { type: Schema.Types.Mixed }, + method: { type: String, enum: ["wallet", "internal_wallet", "gateway", "paystack", "privy", "system"], }, @@ -73,4 +77,4 @@ const TransactionSchema: Schema = new Schema({ }) export default (mongoose.models.Transaction || - mongoose.model("Transaction", TransactionSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; \ No newline at end of file + mongoose.model("Transaction", TransactionSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; diff --git a/package-lock.json b/package-lock.json index f5d658e8..bce25b39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -93,6 +93,7 @@ "jsdom": "^25.0.1", "postcss": "^8.5", "tailwindcss": "^3.4.17", + "tsx": "^4.23.1", "typescript": "^5", "vitest": "^2.1.9" } @@ -960,6 +961,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -977,6 +995,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -994,6 +1029,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -20982,6 +21034,458 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 15800356..38eb1a93 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test": "vitest run", "typecheck": "tsc --noEmit", "start": "next start", + "fx:legacy-check": "tsx scripts/check-legacy-fx-transactions.ts", "demo:pool-asset": "tsx scripts/demo-pool-asset.ts" }, "dependencies": { @@ -110,6 +111,7 @@ "jsdom": "^25.0.1", "postcss": "^8.5", "tailwindcss": "^3.4.17", + "tsx": "^4.23.1", "typescript": "^5", "vitest": "^2.1.9" } diff --git a/scripts/check-legacy-fx-transactions.ts b/scripts/check-legacy-fx-transactions.ts new file mode 100644 index 00000000..7202870a --- /dev/null +++ b/scripts/check-legacy-fx-transactions.ts @@ -0,0 +1,49 @@ +import dbConnect from "@/lib/dbConnect" +import Transaction from "@/models/Transaction" + +async function main() { + await dbConnect() + + const records = await Transaction.find({ + $or: [ + { exchangeRate: { $exists: true } }, + { originalCurrency: { $exists: true } }, + { amountOriginal: { $exists: true } }, + ], + }) + .select("_id amount amountOriginal currency originalCurrency exchangeRate exchangeRateQuoteId") + .lean() + + const valid: string[] = [] + const ambiguous: string[] = [] + + for (const record of records) { + const hasPositiveRate = Number.isFinite(record.exchangeRate) && Number(record.exchangeRate) > 0 + const hasOriginalCurrency = typeof record.originalCurrency === "string" && record.originalCurrency.length === 3 + const hasBookedQuote = Boolean(record.exchangeRateQuoteId) + + if (hasBookedQuote || (hasPositiveRate && hasOriginalCurrency)) { + valid.push(record._id.toString()) + } else { + ambiguous.push(record._id.toString()) + } + } + + console.log( + JSON.stringify( + { + checked: records.length, + validLegacy: valid.length, + ambiguousLegacy: ambiguous.length, + ambiguous, + }, + null, + 2, + ), + ) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +})