diff --git a/__tests__/lib/fx/quote-service.test.ts b/__tests__/lib/fx/quote-service.test.ts index 9a8fb994..e93aa36c 100644 --- a/__tests__/lib/fx/quote-service.test.ts +++ b/__tests__/lib/fx/quote-service.test.ts @@ -2,7 +2,7 @@ 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" +import { convertMajorAmount, parseDecimalToMinorUnits } from "@/lib/fx/types" function createService(adapters: ExchangeRateProviderAdapter[] = [new StaticExchangeRateAdapter({ "USD/NGN": 1500 })]) { return new ExchangeRateQuoteService(adapters, new InMemoryQuoteRepository(), { @@ -26,6 +26,7 @@ describe("ExchangeRateQuoteService", () => { }) expect(quote.convertedAmountMinor).toBe(1_500_000) + await service.lockQuote(quote.id, now) const consumed = await service.consumeQuote({ quoteId: quote.id, @@ -57,6 +58,7 @@ describe("ExchangeRateQuoteService", () => { sourceAmountMajor: 1, now: new Date("2026-01-01T00:00:00.000Z"), }) + await service.lockQuote(quote.id, new Date("2026-01-01T00:00:00.000Z")) await expect( service.consumeQuote({ @@ -70,6 +72,26 @@ describe("ExchangeRateQuoteService", () => { ).rejects.toThrow("expired") }) + it("allows only one concurrent consumer of a locked quote", async () => { + const now = new Date("2026-01-01T00:00:00.000Z") + const service = createService() + const quote = await service.createQuote({ + baseCurrency: "USD", + quoteCurrency: "NGN", + sourceAmountMajor: 3, + now, + }) + await service.lockQuote(quote.id, now) + + const attempts = await Promise.allSettled([ + service.consumeQuote({ quoteId: quote.id, baseCurrency: "USD", quoteCurrency: "NGN", sourceAmountMinor: 300, consumedBy: "a", now }), + service.consumeQuote({ quoteId: quote.id, baseCurrency: "USD", quoteCurrency: "NGN", sourceAmountMinor: 300, consumedBy: "b", now }), + ]) + + expect(attempts.filter((attempt) => attempt.status === "fulfilled")).toHaveLength(1) + expect(attempts.filter((attempt) => attempt.status === "rejected")).toHaveLength(1) + }) + it("supports inverse pairs through the static adapter", async () => { const service = createService([new StaticExchangeRateAdapter({ "USD/NGN": 1500 })]) const quote = await service.createQuote({ @@ -116,6 +138,8 @@ describe("ExchangeRateQuoteService", () => { }) it("uses deterministic minor-unit rounding and rejects invalid rates", () => { + expect(parseDecimalToMinorUnits("1.05", "USD")).toBe(105) + expect(() => parseDecimalToMinorUnits("1.005", "USD")).toThrow("at most 2 decimal") expect( convertMajorAmount({ amountMajor: 1.005, diff --git a/app/api/payments/down-payment/route.ts b/app/api/payments/down-payment/route.ts index 4089a041..d1cb14cf 100644 --- a/app/api/payments/down-payment/route.ts +++ b/app/api/payments/down-payment/route.ts @@ -4,6 +4,10 @@ import User from "@/models/User" import Loan from "@/models/Loan" import { jwtVerify } from "jose" import { cookies } from "next/headers" +import { z } from "zod" +import { ExchangeRateQuoteService } from "@/lib/fx/quote-service" +import { MongooseQuoteRepository } from "@/lib/fx/mongoose-quote-repository" +import { parseDecimalToMinorUnits } from "@/lib/fx/types" function getJwtSecret() { const secret = process.env.JWT_SECRET?.trim() @@ -14,25 +18,12 @@ function getJwtSecret() { return new TextEncoder().encode(secret) } -// Function to get USD/NGN exchange rate (convert USD to NGN) -async function getUSDToNGNRate(): Promise { - try { - // Use exchange rate API to get current rates - const response = await fetch("https://api.exchangerate-api.com/v4/latest/USD") - const data = await response.json() - - if (data.rates && data.rates.NGN) { - return data.rates.NGN // This gives us how many NGN = 1 USD - } - - // Fallback to fixed rate if API fails - throw new Error("Exchange rate API failed") - } catch (error) { - console.warn("Failed to fetch live exchange rate, using fallback rate:", error) - // Fallback rate: approximately 1 USD = 1600 NGN (adjust as needed) - return 1600 - } -} +const requestSchema = z.object({ + loanId: z.string().trim().min(1), + quoteId: z.string().trim().min(1), + amount: z.union([z.string().trim().min(1), z.number().finite().positive()]), + currency: z.literal("USD").default("USD"), +}) export async function POST(request: Request) { try { @@ -52,16 +43,16 @@ export async function POST(request: Request) { const userId = payload.userId as string console.log("User ID from token:", userId) - const { loanId, amount, currency = "USD" } = await request.json() - console.log("Request data:", { loanId, amount, currency }) + const parsed = requestSchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json({ message: "Loan ID, locked quote ID, and exact USD amount are required" }, { status: 400 }) + } + const { loanId, quoteId, amount, currency } = parsed.data + const sourceAmountMinor = parseDecimalToMinorUnits(amount, "USD") + console.log("Request data:", { loanId, quoteId, currency }) const secretKey = process.env.PAYSTACK_SECRET_KEY - if (!loanId || !amount) { - console.log("Missing loanId or amount") - return NextResponse.json({ message: "Loan ID and amount are required" }, { status: 400 }) - } - if (!secretKey) { console.log("Paystack secret key not configured") return NextResponse.json({ message: "Payment service not configured" }, { status: 500 }) @@ -98,16 +89,33 @@ export async function POST(request: Request) { console.log("Down payment already made") return NextResponse.json({ message: "Down payment already completed" }, { status: 400 }) } - console.log("Loan validation passed, proceeding with currency conversion") - - // Convert USD amount to NGN (always convert since Paystack uses NGN) - const exchangeRate = await getUSDToNGNRate() - const amountInNaira = amount * exchangeRate - console.log(`Converting $${amount} to ₦${amountInNaira.toLocaleString()} at rate ${exchangeRate}`) + console.log("Loan validation passed, consuming locked FX quote") + + const quoteService = new ExchangeRateQuoteService([], new MongooseQuoteRepository(), { + maxQuoteAgeMs: 0, + quoteTtlMs: 0, + deviationThresholdBps: 0, + markupBps: 0, + supportedPairs: ["USD/NGN"], + }) + let quote: Awaited> + try { + quote = await quoteService.consumeQuote({ + quoteId, + baseCurrency: "USD", + quoteCurrency: "NGN", + sourceAmountMinor, + consumedBy: `down-payment:${loanId}:${userId}`, + }) + } catch (error) { + return NextResponse.json( + { message: error instanceof Error ? error.message : "FX quote could not be consumed" }, + { status: 409 }, + ) + } - // Paystack expects the amount in kobo (NGN * 100) - const amountInKobo = Math.round(amountInNaira * 100) - console.log("Amount in kobo:", amountInKobo) + // Paystack consumes integer kobo directly from the immutable quote snapshot. + const amountInKobo = quote.convertedAmountMinor // Initialize transaction with Paystack console.log("Calling Paystack API...") @@ -125,10 +133,18 @@ export async function POST(request: Request) { loanId, paymentType: "down_payment", userId, - originalAmountUSD: amount, + quoteId: quote.id, + quoteVersion: quote.version, + sourceAmountMinor: quote.sourceAmountMinor, + convertedAmountMinor: quote.convertedAmountMinor, selectedCurrency: currency, - exchangeRate, - amountNGN: amountInNaira, + exchangeRate: quote.rate, + providerRate: quote.providerRate, + rateSource: quote.provider, + rateTimestamp: quote.providerTimestamp.toISOString(), + quoteFetchedAt: quote.fetchedAt.toISOString(), + spreadBps: quote.spreadBps, + quoteConsumer: quote.consumedBy, }, }), }) @@ -146,9 +162,13 @@ export async function POST(request: Request) { success: true, data: paystackData.data, conversionInfo: { - originalAmountUSD: amount, - convertedAmountNGN: amountInNaira, - exchangeRate, + quoteId: quote.id, + sourceAmountMinor: quote.sourceAmountMinor, + convertedAmountMinor: quote.convertedAmountMinor, + exchangeRate: quote.rate, + rateSource: quote.provider, + rateTimestamp: quote.providerTimestamp, + spreadBps: quote.spreadBps, selectedCurrency: currency, }, }) diff --git a/lib/fx/mongoose-quote-repository.ts b/lib/fx/mongoose-quote-repository.ts index cfc7268d..243fc010 100644 --- a/lib/fx/mongoose-quote-repository.ts +++ b/lib/fx/mongoose-quote-repository.ts @@ -79,4 +79,19 @@ export class MongooseQuoteRepository implements QuoteRepository { if (!document) throw new Error("Quote not found.") return toSnapshot(document) } + + async consume(id: string, consumedBy: string, consumedAt: Date) { + const document = await ExchangeRateQuote.findOneAndUpdate( + { + _id: id, + status: "locked", + expiresAt: { $gte: consumedAt }, + }, + { + $set: { status: "consumed", consumedAt, consumedBy }, + }, + { new: true, runValidators: true }, + ) + return document ? toSnapshot(document) : null + } } diff --git a/lib/fx/quote-service.ts b/lib/fx/quote-service.ts index ce79f435..f2a38f46 100644 --- a/lib/fx/quote-service.ts +++ b/lib/fx/quote-service.ts @@ -22,6 +22,7 @@ export interface QuoteRepository { findById(id: string): Promise findByIdempotencyKey(key: string): Promise update(snapshot: ExchangeRateQuoteSnapshot): Promise + consume(id: string, consumedBy: string, consumedAt: Date): Promise } export class InMemoryQuoteRepository implements QuoteRepository { @@ -48,6 +49,14 @@ export class InMemoryQuoteRepository implements QuoteRepository { this.quotes.set(snapshot.id, structuredClone(snapshot)) return structuredClone(snapshot) } + + async consume(id: string, consumedBy: string, consumedAt: Date) { + const quote = this.quotes.get(id) + if (!quote || quote.status !== "locked" || quote.expiresAt.getTime() < consumedAt.getTime()) return null + const consumed = { ...quote, status: "consumed" as const, consumedAt, consumedBy } + this.quotes.set(id, structuredClone(consumed)) + return structuredClone(consumed) + } } function nowMs(date: Date) { @@ -153,7 +162,8 @@ export class ExchangeRateQuoteService { quoteId: string baseCurrency: string quoteCurrency: string - sourceAmountMajor: number + sourceAmountMajor?: number + sourceAmountMinor?: number direction?: QuoteDirection amountPolicy?: AmountPolicy consumedBy: string @@ -177,20 +187,23 @@ export class ExchangeRateQuoteService { 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 !== "locked") { + if (quote.status === "consumed") throw new Error("Quote has already been consumed.") + throw new Error("Quote must be locked before it can be consumed.") } - if (quote.status === "consumed") { - throw new Error("Quote has already been consumed.") + if (quote.amountPolicy === "exact-source") { + if (input.sourceAmountMinor !== undefined && quote.sourceAmountMinor !== input.sourceAmountMinor) { + throw new Error("Quote source amount does not match the requested conversion.") + } + if (input.sourceAmountMinor === undefined && quote.sourceAmountMajor !== input.sourceAmountMajor) { + throw new Error("Quote source amount does not match the requested conversion.") + } } - return this.repository.update({ - ...quote, - status: "consumed", - consumedAt: now, - consumedBy: input.consumedBy, - }) + const consumed = await this.repository.consume(quote.id, input.consumedBy, now) + if (!consumed) throw new Error("Quote was consumed, expired, or unlocked by another request.") + return consumed } private async resolveProviderQuote(baseCurrency: CurrencyCode, quoteCurrency: CurrencyCode) { diff --git a/lib/fx/types.ts b/lib/fx/types.ts index 20302f7f..364cf776 100644 --- a/lib/fx/types.ts +++ b/lib/fx/types.ts @@ -66,6 +66,19 @@ export function toMinorUnits(amountMajor: number, currency: CurrencyCode) { return Math.round((amountMajor + Number.EPSILON) * multiplier) } +export function parseDecimalToMinorUnits(value: string | number, currency: CurrencyCode) { + const raw = String(value).trim() + if (!/^\d+(?:\.\d+)?$/.test(raw)) throw new Error("Money amount must be a positive decimal.") + const decimals = MINOR_UNITS[currency] + const [whole, fraction = ""] = raw.split(".") + if (fraction.length > decimals) throw new Error(`${currency} amounts support at most ${decimals} decimal places.`) + const minor = BigInt(whole) * BigInt(10 ** decimals) + BigInt(fraction.padEnd(decimals, "0") || "0") + if (minor <= BigInt(0) || minor > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error("Money amount is outside the supported exact range.") + } + return Number(minor) +} + export function fromMinorUnits(amountMinor: number, currency: CurrencyCode) { const multiplier = 10 ** MINOR_UNITS[currency] return amountMinor / multiplier