From e1766f32321dfc224439b920889415815ca237b1 Mon Sep 17 00:00:00 2001 From: Mekjah Date: Sat, 30 May 2026 13:41:32 +0100 Subject: [PATCH] feat: add MCC/MNC prefix validation middleware for destination phone numbers --- src/constants/networkPrefixes.ts | 35 ++++++ .../validateNetworkMiddleware.test.ts | 71 +++++++++++ src/middleware/validateNetworkMiddleware.ts | 114 ++++++++++++++++++ src/routes/transactions.ts | 3 + src/routes/v1/transactions.ts | 3 + 5 files changed, 226 insertions(+) create mode 100644 src/constants/networkPrefixes.ts create mode 100644 src/middleware/__tests__/validateNetworkMiddleware.test.ts create mode 100644 src/middleware/validateNetworkMiddleware.ts diff --git a/src/constants/networkPrefixes.ts b/src/constants/networkPrefixes.ts new file mode 100644 index 00000000..7cd03759 --- /dev/null +++ b/src/constants/networkPrefixes.ts @@ -0,0 +1,35 @@ +export type MobileNetworkName = "MTN" | "AIRTEL" | "ORANGE"; + +/** + * Common network prefixes for target mobile money providers. + * Keys are normalized numeric prefixes used to identify a destination network. + */ +export const NETWORK_PREFIXES: Record = { + // Cameroon + "23765": "ORANGE", + "23766": "AIRTEL", + "23767": "MTN", + "23768": "MTN", + "23769": "ORANGE", + + // Uganda + "25670": "AIRTEL", + "25675": "AIRTEL", + "25677": "MTN", + "25678": "MTN", + + // Ghana + "23324": "MTN", + "23326": "AIRTEL", + "23354": "MTN", + "23355": "MTN", + "23356": "AIRTEL", + "23357": "AIRTEL", + "23359": "MTN", + + // Ivory Coast + "22507": "ORANGE", + + // Senegal + "22177": "ORANGE", +}; diff --git a/src/middleware/__tests__/validateNetworkMiddleware.test.ts b/src/middleware/__tests__/validateNetworkMiddleware.test.ts new file mode 100644 index 00000000..45e602e3 --- /dev/null +++ b/src/middleware/__tests__/validateNetworkMiddleware.test.ts @@ -0,0 +1,71 @@ +import { Request, Response, NextFunction } from "express"; +import { validateNetworkMiddleware } from "../validateNetworkMiddleware"; + +describe("validateNetworkMiddleware", () => { + let req: Partial; + let res: Partial; + let next: NextFunction; + let statusCode: number; + let jsonData: unknown; + + beforeEach(() => { + statusCode = 200; + jsonData = null; + + req = { + body: {}, + }; + + res = { + status: (code: number) => { + statusCode = code; + return res; + }, + json: (data: unknown) => { + jsonData = data; + }, + }; + + next = jest.fn(); + }); + + it("should resolve MTN for a valid international phone number", () => { + req.body = { + phoneNumber: "+237670000000", + }; + + validateNetworkMiddleware(req as Request, res as Response, next); + + expect(next).toHaveBeenCalled(); + expect((req.body as any).resolvedNetwork).toBe("MTN"); + expect(statusCode).toBe(200); + }); + + it("should resolve ORANGE for a valid local phone number", () => { + req.body = { + phoneNumber: "22507123456", + }; + + validateNetworkMiddleware(req as Request, res as Response, next); + + expect(next).toHaveBeenCalled(); + expect((req.body as any).resolvedNetwork).toBe("ORANGE"); + }); + + it("should reject unsupported network prefixes", () => { + req.body = { + phoneNumber: "+1234567890", + }; + + validateNetworkMiddleware(req as Request, res as Response, next); + + expect(next).not.toHaveBeenCalled(); + expect(statusCode).toBe(400); + expect(jsonData).toEqual( + expect.objectContaining({ + error: "Validation failed", + details: expect.any(Array), + }), + ); + }); +}); diff --git a/src/middleware/validateNetworkMiddleware.ts b/src/middleware/validateNetworkMiddleware.ts new file mode 100644 index 00000000..f7095510 --- /dev/null +++ b/src/middleware/validateNetworkMiddleware.ts @@ -0,0 +1,114 @@ +import { Request, Response, NextFunction } from "express"; +import { NETWORK_PREFIXES, type MobileNetworkName } from "../constants/networkPrefixes"; + +const LOCAL_PREFIX_CACHE = Object.entries(NETWORK_PREFIXES).reduce( + (current, [prefix, network]) => { + const countryCode = prefix.slice(0, 3); + const localPrefix = prefix.length > 3 ? prefix.slice(3) : prefix; + current[localPrefix] = network; + return current; + }, + {} as Record, +); + +const sortedNetworkKeys = Object.keys(NETWORK_PREFIXES).sort((a, b) => b.length - a.length); +const sortedLocalKeys = Object.keys(LOCAL_PREFIX_CACHE).sort((a, b) => b.length - a.length); + +function normalizePhoneNumber(rawPhone: string): string { + let digits = rawPhone.trim().replace(/\D/g, ""); + + if (digits.startsWith("00")) { + digits = digits.slice(2); + } + + if (digits.startsWith("0") && digits.length > 1) { + digits = digits.slice(1); + } + + return digits; +} + +function resolveNetworkForDigits(phoneDigits: string): MobileNetworkName | null { + for (const prefix of sortedNetworkKeys) { + if (phoneDigits.startsWith(prefix)) { + return NETWORK_PREFIXES[prefix]; + } + } + + for (const localPrefix of sortedLocalKeys) { + if (phoneDigits.startsWith(localPrefix)) { + return LOCAL_PREFIX_CACHE[localPrefix]; + } + } + + return null; +} + +/** + * Middleware to validate destination mobile network prefixes. + * + * This middleware extracts destinationPhone or phoneNumber from req.body, + * normalizes country-code and local trunk prefixes, resolves the network from + * configured prefixes, and attaches req.body.resolvedNetwork when valid. + */ +export const validateNetworkMiddleware = ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const rawPhone = + typeof req.body.destinationPhone === "string" + ? req.body.destinationPhone + : typeof req.body.phoneNumber === "string" + ? req.body.phoneNumber + : undefined; + + if (!rawPhone) { + return res.status(400).json({ + error: "Validation failed", + details: [ + { + path: "destinationPhone or phoneNumber", + message: "Destination phone number is required", + }, + ], + }); + } + + const normalized = normalizePhoneNumber(rawPhone); + if (!normalized || normalized.length < 4) { + return res.status(400).json({ + error: "Validation failed", + details: [ + { + path: "destinationPhone or phoneNumber", + message: "Invalid phone number format", + }, + ], + }); + } + + const resolvedNetwork = resolveNetworkForDigits(normalized); + if (!resolvedNetwork) { + return res.status(400).json({ + error: "Validation failed", + details: [ + { + path: "destinationPhone or phoneNumber", + message: + "Unsupported destination network prefix. Supported networks are MTN, AIRTEL, and ORANGE.", + }, + ], + }); + } + + (req.body as any).resolvedNetwork = resolvedNetwork; + next(); + } catch (error) { + console.error("Error in validateNetworkMiddleware:", error); + return res.status(500).json({ + error: "An internal server error occurred during network validation", + }); + } +}; diff --git a/src/routes/transactions.ts b/src/routes/transactions.ts index 4490827a..abd8cd4f 100644 --- a/src/routes/transactions.ts +++ b/src/routes/transactions.ts @@ -16,6 +16,7 @@ import { } from "../controllers/transactionController"; import { validateTransaction } from "../middleware/validateTransaction"; import { normalizeProvider } from "../middleware/normalizeProvider"; +import { validateNetworkMiddleware } from "../middleware/validateNetworkMiddleware"; import { TimeoutPresets, haltOnTimedout } from "../middleware/timeout"; import { authenticateToken } from "../middleware/auth"; import { cancelTransactionRateLimiter } from "../middleware/rateLimit"; @@ -165,6 +166,7 @@ transactionRoutes.post( haltOnTimedout, normalizeProvider, validateTransaction, + validateNetworkMiddleware, geolocateMiddleware, depositHandler, ); @@ -178,6 +180,7 @@ transactionRoutes.post( haltOnTimedout, normalizeProvider, validateTransaction, + validateNetworkMiddleware, geolocateMiddleware, withdrawHandler, ); diff --git a/src/routes/v1/transactions.ts b/src/routes/v1/transactions.ts index f93017bd..d95636d4 100644 --- a/src/routes/v1/transactions.ts +++ b/src/routes/v1/transactions.ts @@ -14,6 +14,7 @@ import { deleteMetadataKeysHandler, searchByMetadataHandler, } from "../../controllers/transactionController"; +import { validateNetworkMiddleware } from "../../middleware/validateNetworkMiddleware"; import { TimeoutPresets, haltOnTimedout } from "../../middleware/timeout"; import { validateTransactionFilters } from "../../utils/transactionFilters"; import { requireAuth } from "../../middleware/auth"; @@ -31,6 +32,7 @@ transactionRoutesV1.post( requireAuth, checkAccountStatusStrict, geoFencingMiddleware, + validateNetworkMiddleware, TimeoutPresets.long, haltOnTimedout, setApiVersion("v1"), @@ -44,6 +46,7 @@ transactionRoutesV1.post( requireAuth, checkAccountStatusStrict, geoFencingMiddleware, + validateNetworkMiddleware, TimeoutPresets.long, haltOnTimedout, setApiVersion("v1"),