Skip to content
Open
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
61 changes: 61 additions & 0 deletions __tests__/kes-mpesa-channel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
expandKesMpesaInstitutions,
getOfframpAccountIdentifierPlaceholder,
formatKesMpesaAccountDisplay,
getKesMpesaInstitutionLabel,
KES_MPESA_INSTITUTION_CODE,
} from "../app/utils";
import type { InstitutionProps } from "../app/types";

describe("KES M-Pesa virtual institution split", () => {
const baseInstitutions: InstitutionProps[] = [
{ name: "SAFARICOM", code: "SAFAKEPC", type: "mobile_money" },
{ name: "AIRTEL", code: "AIRTKEPC", type: "mobile_money" },
{ name: "Equity Bank", code: "EQTYKEPC", type: "bank" },
];

it("expands SAFAKEPC into Send Money / Till / Paybill for KES", () => {
const expanded = expandKesMpesaInstitutions(baseInstitutions, "KES");
expect(expanded).toHaveLength(5);
const mpesa = expanded.filter((i) => i.code === KES_MPESA_INSTITUTION_CODE);
expect(mpesa.map((i) => i.name)).toEqual([
"M-PESA (Send Money)",
"M-PESA (Till)",
"M-PESA (Paybill)",
]);
expect(mpesa.every((i) => i.code === "SAFAKEPC")).toBe(true);
expect(mpesa.map((i) => i.channel)).toEqual(["Mobile", "Till", "Paybill"]);
expect(expanded.find((i) => i.code === "AIRTKEPC")?.name).toBe("AIRTEL");
});

it("does not expand SAFAKEPC for non-KES currencies", () => {
const expanded = expandKesMpesaInstitutions(baseInstitutions, "NGN");
expect(expanded).toHaveLength(3);
expect(expanded.find((i) => i.code === "SAFAKEPC")?.name).toBe("SAFARICOM");
});

it("returns channel-aware placeholders", () => {
expect(
getOfframpAccountIdentifierPlaceholder("KES", "mobile_money", "Mobile"),
).toBe("07XXXXXXXX");
expect(
getOfframpAccountIdentifierPlaceholder("KES", "mobile_money", "Till"),
).toBe("Till number (5–7 digits)");
expect(
getOfframpAccountIdentifierPlaceholder("KES", "mobile_money", "Paybill"),
).toBe("Account / reference");
});

it("formats preview/history account lines", () => {
expect(formatKesMpesaAccountDisplay("0712345678", "Mobile")).toBe(
"0712345678 • M-PESA",
);
expect(formatKesMpesaAccountDisplay("123456", "Till")).toBe(
"Till • 123456 • M-PESA",
);
expect(formatKesMpesaAccountDisplay("INV-001", "Paybill", "400200")).toBe(
"Paybill • 400200 / INV-001 • M-PESA",
);
expect(getKesMpesaInstitutionLabel("Till")).toBe("M-PESA (Till)");
});
});
27 changes: 27 additions & 0 deletions app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,20 @@ import type {
UseFormReturn,
} from "react-hook-form";

/** KES M-Pesa payout rail. Till/Paybill share institution SAFAKEPC with channel metadata. */
export type KesMpesaChannel = "Mobile" | "Till" | "Paybill";

export type InstitutionProps = {
name: string;
code: string;
type: "bank" | "mobile_money";
/**
* UI-only key for virtual KES M-Pesa splits (e.g. `SAFAKEPC:Till`).
* Always submit `code` (SAFAKEPC) to the API.
*/
uiKey?: string;
/** Present on virtually expanded KES M-Pesa institution options. */
channel?: KesMpesaChannel;
};

/** Onramp refund bank account (persisted per wallet; v2 order source.refundAccount). */
Expand Down Expand Up @@ -58,6 +68,10 @@ export type FormData = {
isSwapped?: boolean;
/** True after user picks the Receive row asset (fiat off-ramp, token on-ramp). */
receiveDestinationExplicitlySelected: boolean;
/** KES M-Pesa rail when SAFAKEPC is virtually split in the UI. */
kesChannel?: KesMpesaChannel | "";
/** Paybill business number (KES Paybill only). */
businessNumber?: string;
};

export const STEPS = {
Expand Down Expand Up @@ -105,6 +119,10 @@ export type RecipientDetails =
institutionCode: string;
accountIdentifier: string;
currency?: string;
/** KES M-Pesa channel when saved from a virtual institution split. */
channel?: KesMpesaChannel;
/** Paybill business number when channel is Paybill. */
businessNumber?: string;
walletAddress?: never;
};

Expand Down Expand Up @@ -163,6 +181,11 @@ export type SelectFieldProps = {
export type VerifyAccountPayload = {
institution: string;
accountIdentifier: string;
/** KES Till/Paybill: skips phone normalization on the aggregator. */
metadata?: {
channel?: KesMpesaChannel;
businessNumber?: string;
};
};

/** Paycrest v2 rates: onramp uses `buy`, offramp uses `sell`. */
Expand Down Expand Up @@ -568,6 +591,10 @@ export interface Recipient {
institution: string;
account_identifier: string;
memo?: string;
/** KES M-Pesa channel label for history display (e.g. Till, Paybill). */
channel?: KesMpesaChannel;
/** Paybill business number when applicable. */
business_number?: string;
/** Bridge only: destination network (the transactions.network column holds the source). */
to_network?: string;
}
Expand Down
135 changes: 127 additions & 8 deletions app/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createElement, type ReactElement } from "react";
import JSEncrypt from "jsencrypt";
import type {
InstitutionProps,
KesMpesaChannel,
Network,
Token,
Currency,
Expand Down Expand Up @@ -73,17 +74,133 @@ export function getTokenLogoIdentifier(tokenSymbol: string): string {
/**
* Retrieves the institution name based on the provided institution code.
*
* @param code - The institution code.
* @param code - The institution code (or UI key like `SAFAKEPC:Till`).
* @returns The institution name associated with the provided code, or undefined if not found.
*/
export function getInstitutionNameByCode(
code: string,
supportedInstitutions: InstitutionProps[],
): string | undefined {
const institution = supportedInstitutions.find((inst) => inst.code === code);
const institution = supportedInstitutions.find(
(inst) => inst.code === code || inst.uiKey === code,
);
return institution ? institution.name : undefined;
}

/** Safaricom M-Pesa institution code (KES). Till/Paybill use the same code + channel metadata. */
export const KES_MPESA_INSTITUTION_CODE = "SAFAKEPC";

export type { KesMpesaChannel };

const KES_MPESA_VIRTUAL_OPTIONS: {
channel: KesMpesaChannel;
name: string;
}[] = [
{ channel: "Mobile", name: "M-PESA (Send Money)" },
{ channel: "Till", name: "M-PESA (Till)" },
{ channel: "Paybill", name: "M-PESA (Paybill)" },
];

/** UI-only key for a virtually split KES M-Pesa option. */
export function kesMpesaUiKey(channel: KesMpesaChannel): string {
return `${KES_MPESA_INSTITUTION_CODE}:${channel}`;
}

/** Display label for a KES M-Pesa channel (falls back to M-PESA). */
export function getKesMpesaInstitutionLabel(
channel?: KesMpesaChannel | "" | null,
): string {
const match = KES_MPESA_VIRTUAL_OPTIONS.find((o) => o.channel === channel);
return match?.name ?? "M-PESA";
}

/**
* Expand SAFAKEPC into Send Money / Till / Paybill UI options for KES.
* Other institutions (banks, Airtel) are unchanged. API code remains SAFAKEPC.
*/
export function expandKesMpesaInstitutions(
institutions: InstitutionProps[] | undefined,
currency?: string,
): InstitutionProps[] {
if (!institutions?.length) return [];
if ((currency ?? "").toUpperCase() !== "KES") return [...institutions];

const result: InstitutionProps[] = [];
for (const inst of institutions) {
if (inst.code !== KES_MPESA_INSTITUTION_CODE) {
result.push(inst);
continue;
}
for (const option of KES_MPESA_VIRTUAL_OPTIONS) {
result.push({
name: option.name,
code: KES_MPESA_INSTITUTION_CODE,
type: "mobile_money",
channel: option.channel,
uiKey: kesMpesaUiKey(option.channel),
});
}
}
return result;
}

/**
* Preview/history account line for KES M-Pesa rails.
* e.g. `Till • 123456 • M-PESA` or `Paybill • 400200 / INV-001 • M-PESA`.
*/
export function formatKesMpesaAccountDisplay(
accountIdentifier: string,
channel?: KesMpesaChannel | "" | null,
businessNumber?: string | null,
): string {
const id = (accountIdentifier ?? "").trim();
if (channel === "Till") {
return `Till • ${id} • M-PESA`;
}
if (channel === "Paybill") {
const biz = (businessNumber ?? "").trim();
return biz
? `Paybill • ${biz} / ${id} • M-PESA`
: `Paybill • ${id} • M-PESA`;
}
return `${id} • M-PESA`;
}

/** Resolve institution display for preview/history, including KES channel rails. */
export function formatRecipientInstitutionDisplay(
institutionCode: string,
supportedInstitutions: InstitutionProps[],
options?: {
currency?: string;
channel?: KesMpesaChannel | "" | null;
accountIdentifier?: string;
businessNumber?: string | null;
},
): string {
const channel = options?.channel;
const isKesMpesa =
(options?.currency ?? "").toUpperCase() === "KES" &&
institutionCode === KES_MPESA_INSTITUTION_CODE &&
!!channel;

if (isKesMpesa && options?.accountIdentifier !== undefined) {
return formatKesMpesaAccountDisplay(
options.accountIdentifier,
channel,
options.businessNumber,
);
}

if (isKesMpesa) {
return getKesMpesaInstitutionLabel(channel);
}

return (
getInstitutionNameByCode(institutionCode, supportedInstitutions) ??
institutionCode
);
}
Comment on lines +170 to +202

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle channel-less KES M-Pesa recipients explicitly.

When supportedInstitutions contains expandKesMpesaInstitutions(...) output, a legacy recipient without optional channel falls through to getInstitutionNameByCode and is labeled M-PESA (Send Money) because it is the first SAFAKEPC match. Return the generic M-PESA label for KES/SAFAKEPC records with no channel, and add a regression test.

Proposed fix
-  const isKesMpesa =
+  const isKesMpesa =
     (options?.currency ?? "").toUpperCase() === "KES" &&
-    institutionCode === KES_MPESA_INSTITUTION_CODE &&
-    !!channel;
+    institutionCode === KES_MPESA_INSTITUTION_CODE;
 
-  if (isKesMpesa && options?.accountIdentifier !== undefined) {
+  if (isKesMpesa && channel && options?.accountIdentifier !== undefined) {
     return formatKesMpesaAccountDisplay(
       options.accountIdentifier,
       channel,
       options.businessNumber,
     );
   }
 
-  if (isKesMpesa) {
+  if (isKesMpesa && channel) {
     return getKesMpesaInstitutionLabel(channel);
   }
+  if (isKesMpesa) return "M-PESA";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function formatRecipientInstitutionDisplay(
institutionCode: string,
supportedInstitutions: InstitutionProps[],
options?: {
currency?: string;
channel?: KesMpesaChannel | "" | null;
accountIdentifier?: string;
businessNumber?: string | null;
},
): string {
const channel = options?.channel;
const isKesMpesa =
(options?.currency ?? "").toUpperCase() === "KES" &&
institutionCode === KES_MPESA_INSTITUTION_CODE &&
!!channel;
if (isKesMpesa && options?.accountIdentifier !== undefined) {
return formatKesMpesaAccountDisplay(
options.accountIdentifier,
channel,
options.businessNumber,
);
}
if (isKesMpesa) {
return getKesMpesaInstitutionLabel(channel);
}
return (
getInstitutionNameByCode(institutionCode, supportedInstitutions) ??
institutionCode
);
}
export function formatRecipientInstitutionDisplay(
institutionCode: string,
supportedInstitutions: InstitutionProps[],
options?: {
currency?: string;
channel?: KesMpesaChannel | "" | null;
accountIdentifier?: string;
businessNumber?: string | null;
},
): string {
const channel = options?.channel;
const isKesMpesa =
(options?.currency ?? "").toUpperCase() === "KES" &&
institutionCode === KES_MPESA_INSTITUTION_CODE;
if (isKesMpesa && channel && options?.accountIdentifier !== undefined) {
return formatKesMpesaAccountDisplay(
options.accountIdentifier,
channel,
options.businessNumber,
);
}
if (isKesMpesa && channel) {
return getKesMpesaInstitutionLabel(channel);
}
if (isKesMpesa) return "M-PESA";
return (
getInstitutionNameByCode(institutionCode, supportedInstitutions) ??
institutionCode
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/utils.ts` around lines 170 - 202, Update
formatRecipientInstitutionDisplay to explicitly return the generic M-PESA label
for KES recipients whose institutionCode is KES_MPESA_INSTITUTION_CODE and whose
channel is absent, before falling back to getInstitutionNameByCode; preserve the
existing channel-specific and accountIdentifier behavior, and add a regression
test covering expanded M-Pesa institutions with a legacy channel-less recipient.


/**
* Formats a number with commas before the decimal point.
*
Expand Down Expand Up @@ -183,27 +300,29 @@ export const getCurrencySymbol = (currency: string): string => {

/**
* Off-ramp account/phone field placeholder. Banks use a generic label; mobile money
* uses a country-appropriate example. All mobile-money values share an "eg: " prefix
* (no leading space before the label) so the placeholder is aligned in the input.
* uses a country-appropriate example. KES is channel-aware for M-Pesa Send Money / Till / Paybill.
* Examples use local-style numbers only (no international country calling prefix).
*/
export function getOfframpAccountIdentifierPlaceholder(
currency: string,
institutionType: "bank" | "mobile_money" | undefined,
channel?: KesMpesaChannel | "" | null,
): string {
if (institutionType !== "mobile_money") {
return "Account number";
}
if (currency.toUpperCase() === "KES") {
if (channel === "Till") return "Till number (5–7 digits)";
if (channel === "Paybill") return "Account / reference";
return "07XXXXXXXX";
}
const examples: Record<string, string> = {
KES: "07XXXXXXXX",
NGN: "08XXXXXXXX",
UGX: "07XXXXXXXX",
TZS: "07XXXXXXXX",
GHS: "0XXXXXXXXX",
};
return (
examples[currency.toUpperCase()] ?? "eg: phone number"
);
return examples[currency.toUpperCase()] ?? "eg: phone number";
}

/** Fiat codes supported in Noblocks swap (matches `mocks.acceptedCurrencies` names). */
Expand Down
Loading