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
35 changes: 35 additions & 0 deletions src/constants/networkPrefixes.ts
Original file line number Diff line number Diff line change
@@ -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<string, MobileNetworkName> = {
// 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",
};
71 changes: 71 additions & 0 deletions src/middleware/__tests__/validateNetworkMiddleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Request, Response, NextFunction } from "express";
import { validateNetworkMiddleware } from "../validateNetworkMiddleware";

describe("validateNetworkMiddleware", () => {
let req: Partial<Request>;
let res: Partial<Response>;
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),
}),
);
});
});
114 changes: 114 additions & 0 deletions src/middleware/validateNetworkMiddleware.ts
Original file line number Diff line number Diff line change
@@ -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<string, MobileNetworkName>,
);

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",
});
}
};
3 changes: 3 additions & 0 deletions src/routes/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -165,6 +166,7 @@ transactionRoutes.post(
haltOnTimedout,
normalizeProvider,
validateTransaction,
validateNetworkMiddleware,
geolocateMiddleware,
depositHandler,
);
Expand All @@ -178,6 +180,7 @@ transactionRoutes.post(
haltOnTimedout,
normalizeProvider,
validateTransaction,
validateNetworkMiddleware,
geolocateMiddleware,
withdrawHandler,
);
Expand Down
3 changes: 3 additions & 0 deletions src/routes/v1/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -31,6 +32,7 @@ transactionRoutesV1.post(
requireAuth,
checkAccountStatusStrict,
geoFencingMiddleware,
validateNetworkMiddleware,
TimeoutPresets.long,
haltOnTimedout,
setApiVersion("v1"),
Expand All @@ -44,6 +46,7 @@ transactionRoutesV1.post(
requireAuth,
checkAccountStatusStrict,
geoFencingMiddleware,
validateNetworkMiddleware,
TimeoutPresets.long,
haltOnTimedout,
setApiVersion("v1"),
Expand Down
Loading