From 80741aa0f74b8cf306051d39b72bbe2a36df279b Mon Sep 17 00:00:00 2001 From: Jesse Egbosionu Date: Sat, 24 Oct 2026 23:36:39 +0200 Subject: [PATCH] Fix LemFi EUR to NGN rate integration --- package.json | 3 ++- pages/api/v1/rates.js | 43 ++++++++++--------------------------- tests/lemfi.test.js | 50 +++++++++++++++++++++++++++++++++++++++++++ utils/lemfi.js | 33 ++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 33 deletions(-) create mode 100644 tests/lemfi.test.js create mode 100644 utils/lemfi.js diff --git a/package.json b/package.json index f0f5e0c..461649a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "scripts": { "dev": "next dev", "build": "next build", - "start": "next start" + "start": "next start", + "test": "node --test" }, "dependencies": { "next": "^16.4.3", diff --git a/pages/api/v1/rates.js b/pages/api/v1/rates.js index 0b4cdca..e3b9a18 100644 --- a/pages/api/v1/rates.js +++ b/pages/api/v1/rates.js @@ -1,4 +1,4 @@ -import { log } from "console"; +import { fetchLemfiRate } from "../../../utils/lemfi.js"; const COMMON_HEADERS = { 'accept': '*/*', @@ -194,40 +194,19 @@ export default async (req, res) => { }); - const lemfiCall = new Promise((resolve, reject) => { - fetch("https://lemfi.com/api/lemonade/v2/exchange", { - headers: { - accept: "application/json", - "accept-language": "en-GB,en;q=0.9,nl-NL;q=0.8,nl;q=0.7,en-US;q=0.6", - "content-type": "application/json", - priority: "u=1, i", - "sec-ch-ua": '"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"', - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"macOS"', - "sec-fetch-dest": "empty", - "sec-fetch-mode": "cors", - "sec-fetch-site": "same-origin" - }, - referrer: "https://lemfi.com/", - referrerPolicy: "origin", - body: '{"from":"EUR","to":"NGN"}', - method: "POST", - mode: "cors", - credentials: "include" - }).then((response) => resolve(response.json())) - .catch((error) => reject(error)); - }).then((result) => { - return { - rate: 0, - provider: "Lemfi(referal code - OBIN6518)", + const lemfiHref = "https://lemfi.com/en-gb/international-money-transfer"; + const lemfiProvider = "LemFi (referral code - OBIN6518)"; + const lemfiCall = fetchLemfiRate() + .then((rate) => ({ + rate: rate.toFixed(2), + provider: lemfiProvider, bestRate: false, - href: "https://lemfi.com", + href: lemfiHref, logo: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSMYTBiPB_joupdSSoBL1fA0wySDPcMpY92XQ&s" - }; - }) + })) .catch((error) => { - console.error("Error calling Lemfi:", error); - return createDefaultErrorResponse("Lemfi(referal code - OBIN6518)", "https://lemfi.com"); + console.error("Error calling LemFi:", error); + return createDefaultErrorResponse(lemfiProvider, lemfiHref); }); const afriexCall = new Promise((resolve, reject) => { diff --git a/tests/lemfi.test.js b/tests/lemfi.test.js new file mode 100644 index 0000000..c84a72f --- /dev/null +++ b/tests/lemfi.test.js @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { fetchLemfiRate, parseLemfiQuote } from "../utils/lemfi.js"; + +// Captured from LemFi's Netherlands EUR → NGN calculator endpoint. +const quote = { + exchange_from: "EUR", + exchange_to: "NGN", + rate: "9.8057976951626334827662874e+25", + ID: "64eeb42d-7f05-4b50-a172-5e581358517a", +}; + +test("decodes the live quote to the rate displayed by LemFi", () => { + assert.equal(parseLemfiQuote({ data: quote }), 1522); +}); + +test("keeps promotional rewards out of the base rate comparison", () => { + assert.equal(parseLemfiQuote({ data: { ...quote, promotion_rate: { rate: "1e30" } } }), 1522); +}); + +test("rejects malformed rates and currency pairs", () => { + for (const change of [ + { exchange_from: "GBP" }, { exchange_to: "USD" }, + { rate: undefined }, { rate: "invalid" }, { rate: true }, + { rate: "Infinity" }, { rate: "0" }, { rate: "-1" }, + { ID: undefined }, { ID: "abc" }, { ID: "000" }, + ]) { + assert.throws(() => parseLemfiQuote({ data: { ...quote, ...change } })); + } + assert.throws(() => parseLemfiQuote({})); +}); + +test("requests the EUR to NGN Netherlands quote with a timeout", async (t) => { + t.mock.method(globalThis, "fetch", async (url, options) => { + assert.equal(url.origin + url.pathname, "https://lemfi.com/api/exchange-quote"); + assert.deepEqual(Object.fromEntries(url.searchParams), { + from: "EUR", to: "NGN", country: "Netherlands", lang: "en", + }); + assert.ok(options.signal instanceof AbortSignal); + return { ok: true, json: async () => ({ data: quote }) }; + }); + assert.equal(await fetchLemfiRate(), 1522); +}); + +test("rejects upstream HTTP and network errors", async (t) => { + const fetchMock = t.mock.method(globalThis, "fetch", async () => ({ ok: false, status: 503 })); + await assert.rejects(fetchLemfiRate(), /503/); + fetchMock.mock.mockImplementation(async () => { throw new Error("Network unavailable"); }); + await assert.rejects(fetchLemfiRate(), /Network unavailable/); +}); diff --git a/utils/lemfi.js b/utils/lemfi.js new file mode 100644 index 0000000..9fd2df1 --- /dev/null +++ b/utils/lemfi.js @@ -0,0 +1,33 @@ +export function parseLemfiQuote(result) { + const quote = result?.data; + if (quote?.exchange_from !== "EUR" || quote?.exchange_to !== "NGN") { + throw new Error("LemFi did not return a EUR to NGN quote"); + } + + // Match LemFi's calculator: divide the encoded rate by the digits in its ID. + // Use the base rate so referral rewards and LemFi Cash do not inflate comparisons. + const divisor = typeof quote.ID === "string" ? Number(quote.ID.replace(/\D/g, "")) : 0; + const encodedRate = typeof quote.rate === "string" || typeof quote.rate === "number" + ? Number(quote.rate) + : NaN; + const rate = encodedRate / divisor; + if (!Number.isFinite(divisor) || divisor <= 0 || !Number.isFinite(rate) || rate <= 0) { + throw new Error("LemFi returned an invalid exchange rate"); + } + + return rate; +} + +export async function fetchLemfiRate() { + const url = new URL("https://lemfi.com/api/exchange-quote"); + url.search = new URLSearchParams({ from: "EUR", to: "NGN", country: "Netherlands", lang: "en" }); + const response = await fetch(url, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(8000), + }); + if (!response.ok) { + throw new Error(`LemFi quote request failed (${response.status})`); + } + + return parseLemfiQuote(await response.json()); +}