From e02e8044ed90452d9da5298f5b3051f067f63f26 Mon Sep 17 00:00:00 2001 From: Chibuotu Amadi Date: Sun, 26 Jul 2026 01:22:07 +0100 Subject: [PATCH] feat(kes): add M-Pesa channel foundations --- __tests__/kes-mpesa-channel.test.ts | 61 +++++++++++++ app/types.ts | 27 ++++++ app/utils.ts | 135 ++++++++++++++++++++++++++-- 3 files changed, 215 insertions(+), 8 deletions(-) create mode 100644 __tests__/kes-mpesa-channel.test.ts diff --git a/__tests__/kes-mpesa-channel.test.ts b/__tests__/kes-mpesa-channel.test.ts new file mode 100644 index 00000000..ca1954c1 --- /dev/null +++ b/__tests__/kes-mpesa-channel.test.ts @@ -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)"); + }); +}); diff --git a/app/types.ts b/app/types.ts index ae1b3dcc..2709d9fc 100644 --- a/app/types.ts +++ b/app/types.ts @@ -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). */ @@ -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 = { @@ -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; }; @@ -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`. */ @@ -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; } diff --git a/app/utils.ts b/app/utils.ts index 1029ec91..4968e706 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -2,6 +2,7 @@ import { createElement, type ReactElement } from "react"; import JSEncrypt from "jsencrypt"; import type { InstitutionProps, + KesMpesaChannel, Network, Token, Currency, @@ -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 + ); +} + /** * Formats a number with commas before the decimal point. * @@ -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 = { - 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). */