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
26 changes: 25 additions & 1 deletion __tests__/lib/fx/quote-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(), {
Expand All @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -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({
Expand Down Expand Up @@ -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,
Expand Down
102 changes: 61 additions & 41 deletions app/api/payments/down-payment/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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<number> {
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 {
Expand All @@ -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 })
Expand Down Expand Up @@ -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<ReturnType<ExchangeRateQuoteService["consumeQuote"]>>
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...")
Expand All @@ -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,
},
}),
})
Expand All @@ -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,
},
})
Expand Down
15 changes: 15 additions & 0 deletions lib/fx/mongoose-quote-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
35 changes: 24 additions & 11 deletions lib/fx/quote-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface QuoteRepository {
findById(id: string): Promise<ExchangeRateQuoteSnapshot | null>
findByIdempotencyKey(key: string): Promise<ExchangeRateQuoteSnapshot | null>
update(snapshot: ExchangeRateQuoteSnapshot): Promise<ExchangeRateQuoteSnapshot>
consume(id: string, consumedBy: string, consumedAt: Date): Promise<ExchangeRateQuoteSnapshot | null>
}

export class InMemoryQuoteRepository implements QuoteRepository {
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
13 changes: 13 additions & 0 deletions lib/fx/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading