From 2635d7303efa54eb656931ad42a4622b461d1245 Mon Sep 17 00:00:00 2001 From: Martin Bhuong Date: Tue, 19 May 2026 08:45:07 +0300 Subject: [PATCH 1/2] initial plans. for the Implementation of Multi-currency support. his will allow and enable the system, to properly handle, finachi API, wit multiple Payment gateways as well. --- ...currency-mobile-wallet-aggregation-plan.md | 1225 +++++++++++++++++ 1 file changed, 1225 insertions(+) create mode 100644 .trae/documents/2026-05-19-multi-currency-mobile-wallet-aggregation-plan.md diff --git a/.trae/documents/2026-05-19-multi-currency-mobile-wallet-aggregation-plan.md b/.trae/documents/2026-05-19-multi-currency-mobile-wallet-aggregation-plan.md new file mode 100644 index 0000000..641e618 --- /dev/null +++ b/.trae/documents/2026-05-19-multi-currency-mobile-wallet-aggregation-plan.md @@ -0,0 +1,1225 @@ +# Multi-Currency & Mobile Wallet Aggregation Framework — Implementation Plan + +## Executive Summary + +This plan covers two interconnected initiatives: +1. **Multi-Currency Support** — System-wide currency architecture with ISO 4217 compliance, real-time exchange rates, and M-Pesa KES-lock +2. **Mobile Wallet Aggregation** — Refactor the monolithic M-Pesa integration into a generalized provider framework supporting M-Pesa, Airtel Money, Sasapay, and future providers + +Both initiatives share foundational changes (monetary schema, payment provider abstraction) and are planned sequentially across three phases. + +--- + +## Current Architecture Assessment + +### Monetary Value Handling +- All `numeric` columns use fixed `(15, 2)` — KES-centric with 2 decimal places +- Only `accounts.currency` column tracks currency (default `'KES'`, unused for conversion) +- Zero exchange rate infrastructure +- Frontend has hardcoded `formatKES()` — no currency-aware formatting +- Backend uses `decimal.js` at 15-digit precision, `ROUND_HALF_UP` + +### M-Pesa Integration +- **SMS-based** (not Daraja API) — parser handles 7 SMS patterns +- Tightly coupled: `mpesaTransactions` table, `mpesa-parser.ts` (duplicated server/client), `mpesa-router.ts` +- Hardcoded enums: `typeEnum = ["cash", "mpesa", "bank_account"]`, `paymentMethodEnum = ["cash", "mpesa", "bank_transfer"]` +- No Daraja webhook integration; general webhook system exists in `integrations-router.ts` (unwired) +- SMS parser duplicated across `api/mpesa-parser.ts` and `src/lib/mpesa-parser.ts` +- M-PESA is referenced in 25+ files across the codebase + +--- + +## Phase 1: Foundation (Core Data Models & Abstraction Layer) + +### Goal +Establish the shared schema, abstract interfaces, and utility infrastructure that both initiatives depend on, with 90%+ unit test coverage. + +### 1.1 Multi-Currency: Database Schema Changes + +#### 1.1.1 New Tables + +**`supported_currencies`** +```typescript +// db/schema.ts +export const supportedCurrencies = pgTable("supported_currencies", { + code: varchar("code", { length: 3 }).primaryKey(), // ISO 4217: KES, USD, UGX, TZS, EUR, GBP, JPY, KWD, etc. + name: varchar("name", { length: 100 }).notNull(), // Kenyan Shilling + symbol: varchar("symbol", { length: 10 }).notNull(), // KSh, $, €, £ + decimalPlaces: integer("decimal_places").notNull().default(2),// 0 (JPY), 2 (KES/USD), 3 (KWD) + isActive: boolean("is_active").default(true).notNull(), + isDefault: boolean("is_default").default(false).notNull(), // Only one per system + createdAt, updatedAt, +}); +``` + +**`exchange_rates`** +```typescript +export const exchangeRates = pgTable("exchange_rates", { + id: serial("id").primaryKey(), + fromCurrency: varchar("from_currency", { length: 3 }).notNull().references(() => supportedCurrencies.code), + toCurrency: varchar("to_currency", { length: 3 }).notNull().references(() => supportedCurrencies.code), + rate: numeric("rate", { precision: 18, scale: 8 }).notNull(), // e.g., 0.0075 for KES→USD + source: varchar("source", { length: 50 }).default("manual"), // 'manual', 'exchange_rate_api', 'open_exchange_rates' + validFrom: timestamp("valid_from").notNull().defaultNow(), + validUntil: timestamp("valid_until"), // NULL = current + createdAt, +}); +// Unique composite: same from→to can't have overlapping validity ranges without explicit versioning +``` + +**`business_currencies`** (per-business currency configuration) +```typescript +export const businessCurrencies = pgTable("business_currencies", { + id: serial("id").primaryKey(), + businessId: bigint("businessId", { mode: "number" }).notNull().references(() => businesses.id), + currency: varchar("currency", { length: 3 }).notNull().references(() => supportedCurrencies.code), + isBaseCurrency: boolean("is_base_currency").default(false).notNull(), // Reporting currency + isActive: boolean("is_active").default(true).notNull(), + createdAt, updatedAt, +}); +``` + +#### 1.1.2 Schema Migrations for Existing Tables + +Add `currency` column to all monetary tables. For KES-only existing data, default to `'KES'`: + +| Table | Columns to Add | New Columns | +|---|---|---| +| `accounts` | *(already has `currency`)* | — | +| `expenses` | `currency` | `varchar(3) default 'KES'` | +| `expense_items` | `currency` | `varchar(3) default 'KES'` | +| `bills` | `currency` | `varchar(3) default 'KES'` | +| `bill_items` | `currency` | `varchar(3) default 'KES'` | +| `bill_payments` | `currency` | `varchar(3) default 'KES'` | +| `daily_sales` | `currency` | `varchar(3) default 'KES'` | +| `daily_sale_payments` | `currency` | `varchar(3) default 'KES'` | +| `journal_lines` | `currency` | `varchar(3) default 'KES'` | +| `ledger_entries` | `currency` | `varchar(3) default 'KES'` | +| `payroll_entries` | `currency` | `varchar(3) default 'KES'` | +| `payroll_advances` | `currency` | `varchar(3) default 'KES'` | +| `suppliers` | `currency` | `varchar(3) default 'KES'` | +| `budgets` | `currency` | `varchar(3) default 'KES'` | +| `purchase_orders` | `currency` | `varchar(3) default 'KES'` | +| `purchase_order_items` | `currency` | `varchar(3) default 'KES'` | +| `items` | `currency` | `varchar(3) default 'KES'` | +| `fixed_asset_depreciation` | `currency` | `varchar(3) default 'KES'` | +| `partner_commissions` | `currency` | `varchar(3) default 'KES'` | + +Add `baseCurrency` and `baseAmount` columns to core reporting tables (for cross-currency aggregation): + +| Table | New Columns | +|---|---| +| `daily_sales` | `baseCurrency varchar(3), baseAmount numeric(15,2)` | +| `journal_lines` | `baseCurrency varchar(3), baseAmount numeric(15,2)` | +| `expenses` | `baseCurrency varchar(3), baseAmount numeric(15,2)` | +| `bills` | `baseCurrency varchar(3), baseAmount numeric(15,2)` | + +#### 1.1.3 Frontend Types Update + +- Update `src/lib/utils.ts`: Replace `formatKES()` with `formatCurrency(amount, currency, options?)` — currency-aware formatter using `Intl.NumberFormat` with the currency code +- Add `SUPPORTED_CURRENCIES` constant in `src/const.ts` (derived from API on mount, with static fallback) +- Add currency selector UI component (`CurrencySelect.tsx`) + +### 1.2 Multi-Currency: Core Services + +#### 1.2.1 Currency Conversion Service + +**File:** `api/lib/currency-converter.ts` + +```typescript +// Core interface +interface CurrencyConverterConfig { + cacheTTL: number; // milliseconds (default: 5 minutes) + defaultProvider: 'exchange_rate_api' | 'open_exchange_rates' | 'manual'; + apiKey?: string; + baseCurrency: string; // System base (default: KES) +} + +// Public API +class CurrencyConverter { + constructor(config: CurrencyConverterConfig); + + // Fetch latest rate with caching + async getRate(from: string, to: string): Promise; + + // Convert amount with optional rounding + async convert(amount: Decimal, from: string, to: string, options?: { + round?: boolean; + decimalPlaces?: number; + }): Promise<{ converted: Decimal; rate: Decimal; fee?: Decimal }>; + + // Batch convert for report aggregation + async batchConvert(amounts: Array<{ amount: Decimal; currency: string }>, toCurrency: string): Promise; + + // Refresh all cached rates from provider + async refreshRates(): Promise; + + // Retrieve latest exchange rates for admin + async getLatestRates(): Promise; +} +``` + +**Key behaviors:** +- In-memory cache with configurable TTL (default 300s) +- Cache-busting on admin rate update +- Stale-data safeguard: rates > 24h old trigger warning log +- Fallback chain: cache → DB → external provider +- For KES→KES, return 1.0 instantly (no DB hit) +- All conversions return `Decimal` (not `number`) per codebase convention + +#### 1.2.2 Exchange Rate Sync Service + +**File:** `api/lib/exchange-rate-sync.ts` + +- Background job (runs hourly via `boot.ts` timer or cron endpoint) +- Fetches from configured provider (ExchangeRate-API, Open Exchange Rates) +- Upserts into `exchange_rates` table with `source`, `validFrom`/`validUntil` +- Logs sync failures to audit log +- Configurable via environment variables: + ``` + EXCHANGE_RATE_PROVIDER=exchange_rate_api|open_exchange_rates|manual + EXCHANGE_RATE_API_KEY=your_key + EXCHANGE_RATE_BASE_CURRENCY=KES + EXCHANGE_RATE_SYNC_INTERVAL=3600000 + ``` + +### 1.3 Mobile Wallet: Database Schema Changes + +#### 1.3.1 New Tables + +**`mobile_wallet_providers`** (registry) +```typescript +export const mobileWalletProviders = pgTable("mobile_wallet_providers", { + code: varchar("code", { length: 20 }).primaryKey(), // 'mpesa', 'airtel_money', 'sasapay' + name: varchar("name", { length: 100 }).notNull(), // 'M-PESA', 'Airtel Money', 'Sasapay' + displayName: varchar("display_name", { length: 100 }), + brandColor: varchar("brand_color", { length: 7 }), // '#C73E1D' + logoUrl: varchar("logo_url", { length: 255 }), + supportedCurrencies: varchar("supported_currencies", { length: 100 }), // Comma-separated: 'KES','KES,UGX,TZS' + isActive: boolean("is_active").default(true).notNull(), + requiresProvisioning: boolean("requires_provisioning").default(false), + configSchema: jsonb("config_schema"), // JSON Schema for provider-specific config + createdAt, updatedAt, deletedAt, +}); +``` + +**`mobile_wallet_transactions`** (replaces `mpesaTransactions`) +```typescript +export const mobileWalletTransactions = pgTable("mobile_wallet_transactions", { + id: serial("id").primaryKey(), + locationId: bigint("locationId", { mode: "number" }).notNull(), + provider: varchar("provider", { length: 20 }).notNull().references(() => mobileWalletProviders.code), + providerTxnId: varchar("provider_txn_id", { length: 100 }).notNull(), // Provider's transaction ID + providerRef: varchar("provider_ref", { length: 100 }), // Additional reference (e.g., till number) + txnDate: date("txnDate").notNull(), + txnTime: varchar("txnTime", { length: 10 }), + txnType: varchar("txn_type", { length: 30 }).notNull(), // Unified type taxonomy + direction: varchar("direction", { length: 5 }).notNull(), // 'in' | 'out' + partyName: varchar("partyName", { length: 255 }), + partyIdentifier: varchar("party_identifier", { length: 100 }), + amount: numeric("amount", { precision: 15, scale: 2 }).notNull(), + currency: varchar("currency", { length: 3 }).default("KES").notNull(), + txnFee: numeric("txnFee", { precision: 15, scale: 2 }).default("0.00").notNull(), + balance: numeric("balance", { precision: 15, scale: 2 }), + description: text("description"), + rawText: text("rawText"), // For SMS-based providers + rawPayload: jsonb("raw_payload"), // For API-based providers (webhook payload) + status: varchar("status", { length: 20 }).default("completed").notNull(), // 'pending','completed','failed','refunded' + isReconciled: boolean("is_reconciled").default(false).notNull(), + isLinked: boolean("is_linked").default(false).notNull(), + linkedExpenseId: bigint("linkedExpenseId", { mode: "number" }), + linkedBillId: bigint("linkedBillId", { mode: "number" }), + linkedSupplierId: bigint("linkedSupplierId", { mode: "number" }), + sourceAccountId: bigint("sourceAccountId", { mode: "number" }).references(() => accounts.id), + destinationAccountId: bigint("destinationAccountId", { mode: "number" }).references(() => accounts.id), + importedBy: bigint("importedBy", { mode: "number" }), + // Multi-currency fields + baseCurrency: varchar("base_currency", { length: 3 }), + baseAmount: numeric("base_amount", { precision: 15, scale: 2 }), + conversionRate: numeric("conversion_rate", { precision: 18, scale: 8 }), + createdAt, updatedAt, deletedAt, +}); +// Unique: per-provider transaction ID must be unique +// Unique constraint on (provider, providerTxnId) +``` + +**`mobile_wallet_daily_ledger`** (replaces `dailyMpesaLedger`) +```typescript +export const mobileWalletDailyLedger = pgTable("mobile_wallet_daily_ledger", { + id: serial("id").primaryKey(), + locationId: bigint("locationId", { mode: "number" }).notNull(), + provider: varchar("provider", { length: 20 }).notNull().references(() => mobileWalletProviders.code), + accountId: bigint("accountId", { mode: "number" }).notNull().references(() => accounts.id), + ledgerDate: date("ledgerDate").notNull(), + openingBalance: numeric("openingBalance", { precision: 15, scale: 2 }).notNull(), + totalInflow: numeric("totalInflow", { precision: 15, scale: 2 }).default("0.00").notNull(), + totalOutflow: numeric("totalOutflow", { precision: 15, scale: 2 }).default("0.00").notNull(), + totalFees: numeric("totalFees", { precision: 15, scale: 2 }).default("0.00").notNull(), + closingBalance: numeric("closingBalance", { precision: 15, scale: 2 }).notNull(), + transactionCount: integer("transactionCount").default(0), + notes: text("notes"), + // Multi-currency base amounts for cross-provider reporting + baseCurrency: varchar("base_currency", { length: 3 }), + baseClosingBalance: numeric("base_closing_balance", { precision: 15, scale: 2 }), + enteredBy: bigint("enteredBy", { mode: "number" }), + createdAt, updatedAt, deletedAt, +}); +// Unique: one ledger entry per location+provider+account+date +``` + +**`mobile_wallet_reconciliation`** (replaces `mpesaReconciliation`) +```typescript +export const mobileWalletReconciliation = pgTable("mobile_wallet_reconciliation", { + id: serial("id").primaryKey(), + provider: varchar("provider", { length: 20 }).notNull().references(() => mobileWalletProviders.code), + txnDate: date("txnDate").notNull(), + orphanCount: integer("orphanCount").default(0), + orphanTotal: numeric("orphanTotal", { precision: 15, scale: 2 }).default("0.00"), + matchedCount: integer("matchedCount").default(0), + matchedTotal: numeric("matchedTotal", { precision: 15, scale: 2 }).default("0.00"), + status: statusEnum("status").default("open").notNull(), + notes: text("notes"), + createdAt, resolvedAt, +}); +``` + +**`provider_configs`** (per-location provider configuration) +```typescript +export const providerConfigs = pgTable("provider_configs", { + id: serial("id").primaryKey(), + locationId: bigint("locationId", { mode: "number" }).notNull(), + provider: varchar("provider", { length: 20 }).notNull().references(() => mobileWalletProviders.code), + accountId: bigint("accountId", { mode: "number" }).notNull().references(() => accounts.id), + isDefault: boolean("is_default").default(false).notNull(), + config: jsonb("config"), // Provider-specific config (API keys, till numbers, etc.) + isActive: boolean("is_active").default(true).notNull(), + createdAt, updatedAt, deletedAt, +}); +// Unique: one config per location+provider+account +``` + +#### 1.3.2 Enum Changes + +**`typeEnum`**: Add new account types for each mobile wallet provider +```typescript +// Before: ["cash", "mpesa", "bank_account"] +// After: ["cash", "mpesa", "airtel_money", "sasapay", "bank_account"] +``` + +**`paymentMethodEnum`**: Add new payment methods +```typescript +// Before: ["cash", "mpesa", "bank_transfer"] +// After: ["cash", "mpesa", "airtel_money", "sasapay", "bank_transfer"] +``` + +**`paymentMethod2Enum`**: +```typescript +// Before: ["cash", "mpesa", "bank_transfer", "card"] +// After: ["cash", "mpesa", "airtel_money", "sasapay", "bank_transfer", "card"] +``` + +#### 1.3.3 Data Migration Strategy + +Create migration script `scripts/migrate-mpesa-to-provider-framework.ts`: +1. Seed `mobile_wallet_providers` with `{ code: 'mpesa', name: 'M-PESA', brandColor: '#C73E1D', ... }` +2. Seed `supported_currencies` with 10-15 common African currencies +3. Migrate `mpesaTransactions` → `mobile_wallet_transactions` (provider = 'mpesa', currency = 'KES') +4. Migrate `dailyMpesaLedger` → `mobile_wallet_daily_ledger` (provider = 'mpesa') +5. Migrate `mpesaReconciliation` → `mobile_wallet_reconciliation` (provider = 'mpesa') +6. Migrate `locations.defaultMpesaAccountId` → `provider_configs` (provider = 'mpesa') +7. Verify row counts match between old and new tables +8. Backfill `baseCurrency`/`baseAmount` = 'KES'/amount for all migrated rows +9. Update `business-reset.ts` to reference new tables + +### 1.4 Mobile Wallet: Abstract Provider Layer + +#### 1.4.1 Abstract Base Class & Interface + +**File:** `api/lib/mobile-wallet/provider-interface.ts` + +```typescript +// Unified type taxonomy for mobile wallet transactions +export type WalletTxnType = + | "payment" // Payment to a business (C2B) + | "disbursement" // Business payout to customer (B2C) + | "transfer" // Person-to-person transfer + | "topup" // Wallet funding from bank + | "withdrawal" // Cash withdrawal from agent + | "airtime" // Airtime purchase + | "utility" // Utility bill payment (KPLC, etc.) + | "bank_transfer" // Transfer to/from bank account + | "refund"; // Reversed/refunded transaction + +export type WalletDirection = "in" | "out"; +export type WalletTxnStatus = "pending" | "completed" | "failed" | "refunded"; + +export interface WalletTransactionRequest { + amount: number | string; + currency: string; + partyIdentifier: string; // Phone number, till number, paybill + reference: string; // Your internal reference + description?: string; + metadata?: Record; +} + +export interface WalletTransactionResult { + success: boolean; + providerTxnId: string; + providerRef?: string; + status: WalletTxnStatus; + amount: string; + currency: string; + fee?: string; + balance?: string; + errorMessage?: string; + rawResponse?: Record; +} + +export interface WalletStatusResult { + providerTxnId: string; + status: WalletTxnStatus; + amount: string; + currency: string; + fee?: string; + balance?: string; + errorMessage?: string; +} + +export interface WalletWebhookPayload { + provider: string; + rawBody: string; + headers: Record; + signature?: string; +} + +export interface WalletWebhookResult { + processed: boolean; + transaction?: WalletTransactionResult; + error?: string; +} + +export interface WalletBalanceResult { + provider: string; + accountId: number; + balance: string; + currency: string; + asOf: Date; +} + +export abstract class BaseWalletProvider { + abstract readonly code: string; + abstract readonly displayName: string; + abstract readonly supportedCurrencies: string[]; + abstract readonly features: { + initiatePayment: boolean; + queryStatus: boolean; + processWebhook: boolean; + refund: boolean; + balanceInquiry: boolean; + smsImport: boolean; + }; + + // Core operations + abstract initiatePayment(request: WalletTransactionRequest): Promise; + abstract queryStatus(providerTxnId: string): Promise; + abstract processWebhook(payload: WalletWebhookPayload): Promise; + abstract processRefund(providerTxnId: string, amount?: string): Promise; + abstract balanceInquiry(accountId: number): Promise; + + // SMS import (supported by M-PESA, not by API-based providers) + abstract parseSms?(text: string, options?: Record): Promise; + abstract generateSmsPreview?(text: string, options?: Record): Promise; + + // Common utilities + protected parseDecimal(value: string | number): Decimal { return d(value); } + protected validateCurrency(currency: string): void { + if (!this.supportedCurrencies.includes(currency)) { + throw new Error(`Currency ${currency} not supported by ${this.displayName}. Supported: ${this.supportedCurrencies.join(', ')}`); + } + } + + // Logger + abstract logError(context: string, error: unknown, metadata?: Record): void; +} + +export interface ParsedWalletSms { + providerTxnId: string; + date: string; + time?: string; + amount: string; + currency: string; + txnType: WalletTxnType; + direction: WalletDirection; + partyName?: string; + partyIdentifier?: string; + balance?: string; + txnFee?: string; + rawText: string; +} +``` + +#### 1.4.2 Provider Registry + +**File:** `api/lib/mobile-wallet/provider-registry.ts` + +```typescript +class WalletProviderRegistry { + private providers = new Map(); + + register(provider: BaseWalletProvider): void; + get(code: string): BaseWalletProvider; + getAll(): BaseWalletProvider[]; + getActive(): BaseWalletProvider[]; + getByCurrency(currency: string): BaseWalletProvider[]; + + // Load provider config for a location + getProviderConfig(locationId: number, provider: string): Promise; + + // Validate that a transaction's currency is supported by the provider + validateCurrencyConstraint(provider: string, currency: string): boolean; +} + +export const walletRegistry = new WalletProviderRegistry(); +``` + +#### 1.4.3 Concrete M-PESA Provider + +**File:** `api/lib/mobile-wallet/providers/mpesa-provider.ts` + +```typescript +export class MpesaProvider extends BaseWalletProvider { + readonly code = 'mpesa'; + readonly displayName = 'M-PESA'; + readonly supportedCurrencies = ['KES']; // KES-only lock enforced + readonly features = { + initiatePayment: false, // SMS-based, no API initiation + queryStatus: false, + processWebhook: false, + refund: false, // Manual via SMS + balanceInquiry: false, + smsImport: true, // This is M-PESA's main integration mechanism + }; + + // Implement SMS parsing (migrated from mpesa-parser.ts) + async parseSms(text: string): Promise; + async generateSmsPreview(text: string): Promise; + + // For future Daraja API integration - stubs return "not implemented" + async initiatePayment(): Promise { throw new Error('Not implemented in SMS mode'); } + async queryStatus(): Promise { throw new Error('Not implemented in SMS mode'); } + async processWebhook(): Promise { throw new Error('Not implemented in SMS mode'); } + async processRefund(): Promise { throw new Error('Not implemented in SMS mode'); } + async balanceInquiry(): Promise { throw new Error('Not implemented in SMS mode'); } +} +``` + +**Key behavior — KES-only currency lock:** +```typescript +// In the provider's request validation +validateCurrencyConstraint(provider: string, currency: string): boolean { + if (provider === 'mpesa' && currency !== 'KES') { + return false; // Block non-KES M-PESA transactions + } + return true; +} +``` + +The M-PESA SMS parser logic is migrated from `api/mpesa-parser.ts` into this class, with: +- All 7 SMS pattern recognizers preserved +- Currency field defaults to 'KES' +- `ParsedWalletSms` output type replaces old `ParsedMpesaSms` +- Server-side and client-side parsers now unified via shared import + +#### 1.4.4 Provider Templates (Boilerplate) + +**File:** `api/lib/mobile-wallet/providers/_template-provider.ts` + +A boilerplate template for adding new providers: + +```typescript +import { BaseWalletProvider, WalletTransactionRequest, WalletTransactionResult, ... } from '../provider-interface'; + +export class NewProvider extends BaseWalletProvider { + readonly code = 'new_provider_code'; + readonly displayName = 'Provider Display Name'; + readonly supportedCurrencies = ['KES']; + readonly features = { + initiatePayment: true, + queryStatus: true, + processWebhook: true, + refund: false, + balanceInquiry: false, + smsImport: false, + }; + + async initiatePayment(request: WalletTransactionRequest): Promise { + this.validateCurrency(request.currency); + // 1. Authenticate with provider API + // 2. Send payment request + // 3. Parse response + // 4. Return standardized result + throw new Error('Not implemented'); + } + + async queryStatus(providerTxnId: string): Promise { + throw new Error('Not implemented'); + } + + async processWebhook(payload: WalletWebhookPayload): Promise { + // 1. Verify signature + // 2. Parse webhook payload + // 3. Return standardized result or error + throw new Error('Not implemented'); + } + + // ... other methods +} +``` + +### 1.5 Unified Transaction Logging + +**File:** `api/lib/mobile-wallet/transaction-logger.ts` + +Centralized logging for all mobile wallet operations: + +```typescript +// Records all mobile wallet transactions with consistent fields +async function logWalletTransaction(params: { + locationId: number; + provider: string; + providerTxnId: string; + amount: string; + currency: string; + direction: WalletDirection; + txnType: WalletTxnType; + status: WalletTxnStatus; + partyName?: string; + fee?: string; + rawPayload?: object; + errorMessage?: string; + sourceAccountId?: number; + destinationAccountId?: number; + // Multi-currency + baseCurrency?: string; + baseAmount?: string; + conversionRate?: string; +}): Promise; // Returns transaction ID + +// Retrieve transactions with cross-provider filtering +async function listWalletTransactions(filters: { + locationId: number; + provider?: string; + dateFrom?: string; + dateTo?: string; + status?: WalletTxnStatus; + direction?: WalletDirection; + currency?: string; + unlinkedOnly?: boolean; +}): Promise; + +// Aggregate stats across all providers +async function getWalletStats(filters: { + locationId: number; + provider?: string; + dateFrom?: string; + dateTo?: string; +}): Promise<{ + totalInflow: Record; // by currency + totalOutflow: Record; // by currency + totalFees: Record; // by currency + transactionCount: number; + byProvider: Array<{ + provider: string; + totalIn: string; + totalOut: string; + count: number; + }>; +}>; +``` + +### 1.6 Unified Webhook Handler + +**File:** `api/lib/mobile-wallet/webhook-handler.ts` + +```typescript +export async function handleWalletWebhook(payload: WalletWebhookPayload): Promise<{ status: number; body: string }> { + const provider = walletRegistry.get(payload.provider); + if (!provider) { + return { status: 404, body: JSON.stringify({ error: `Unknown provider: ${payload.provider}` }) }; + } + + if (!provider.features.processWebhook) { + return { status: 405, body: JSON.stringify({ error: `Webhooks not supported by ${payload.provider}` }) }; + } + + const result = await provider.processWebhook(payload); + if (result.processed) { + // Log to mobile_wallet_transactions + await logWalletTransaction({ ...result.transaction!, ... }); + return { status: 200, body: JSON.stringify({ received: true }) }; + } + + return { status: 400, body: JSON.stringify({ error: result.error }) }; +} +``` + +Mounted in `integrations-router.ts` as a new endpoint: +``` +POST /api/integrations/webhooks/wallet/:provider +``` + +### 1.7 Permissions Update + +**File:** `src/lib/permissions.ts` + +Refactor M-PESA permissions to wallet-agnostic permissions: + +```typescript +// New wallet permissions (replacing MPESA_VIEW, MPESA_IMPORT) +export const WALLET_VIEW = "wallet:view"; +export const WALLET_IMPORT = "wallet:import"; +export const WALLET_ADMIN = "wallet:admin"; // Manage provider configs + +// Legacy aliases for backward compatibility (deprecated) +export const MPESA_VIEW = WALLET_VIEW; +export const MPESA_IMPORT = WALLET_IMPORT; +``` + +### 1.8 Frontend: Currency Utility & Provider Registry + +#### 1.8.1 Shared Currency Formatter + +**File:** `src/lib/currency.ts` + +```typescript +export const SUPPORTED_CURRENCIES = [ + { code: 'KES', name: 'Kenyan Shilling', symbol: 'KSh', decimalPlaces: 2 }, + { code: 'USD', name: 'US Dollar', symbol: '$', decimalPlaces: 2 }, + { code: 'UGX', name: 'Ugandan Shilling', symbol: 'USh', decimalPlaces: 0 }, + { code: 'TZS', name: 'Tanzanian Shilling', symbol: 'TSh', decimalPlaces: 2 }, + { code: 'EUR', name: 'Euro', symbol: '€', decimalPlaces: 2 }, + { code: 'GBP', name: 'British Pound', symbol: '£', decimalPlaces: 2 }, + // ... +]; + +export function formatCurrency( + amount: string | number, + currency: string = 'KES', + options?: { showCode?: boolean; compact?: boolean } +): string { + const num = typeof amount === 'string' ? parseFloat(amount) : amount; + if (isNaN(num)) return `${currency} 0.00`; + + // Use locale based on currency + const localeMap: Record = { + KES: 'en-KE', USD: 'en-US', UGX: 'en-UG', TZS: 'en-TZ', + EUR: 'de-DE', GBP: 'en-GB', JPY: 'ja-JP', + }; + + return new Intl.NumberFormat(localeMap[currency] || 'en-US', { + style: 'currency', + currency, + minimumFractionDigits: options?.compact ? 0 : 2, + maximumFractionDigits: options?.compact ? 0 : 2, + currencyDisplay: options?.showCode ? 'code' : 'symbol', + }).format(num); +} + +export function getCurrencySymbol(currency: string): string { + const c = SUPPORTED_CURRENCIES.find(c => c.code === currency); + return c?.symbol || currency; +} + +export function addCurrencySuffix(amount: string, currency: string): string { + return `${amount} ${currency}`; +} + +// Simplified for common use (backward compat) +export function formatKES(amount: string | number): string { + return formatCurrency(amount, 'KES'); +} +``` + +#### 1.8.2 Provider Constants + +**File:** `src/const.ts` (add wallet provider constants) + +```typescript +export const WALLET_PROVIDERS = [ + { code: 'mpesa', name: 'M-PESA', brandColor: '#C73E1D', icon: 'Smartphone' }, + { code: 'airtel_money', name: 'Airtel Money', brandColor: '#E30613', icon: 'Smartphone' }, + { code: 'sasapay', name: 'Sasapay', brandColor: '#00A651', icon: 'Wallet' }, +] as const; + +export type WalletProviderCode = typeof WALLET_PROVIDERS[number]['code']; +``` + +### 1.9 Unit Tests (Phase 1) + +| Module | Test File | Coverage Target | +|---|---|---| +| `currency-converter.ts` | `api/lib/__tests__/currency-converter.test.ts` | 95% | +| `exchange-rate-sync.ts` | `api/lib/__tests__/exchange-rate-sync.test.ts` | 90% | +| `provider-interface.ts` | `api/lib/__tests__/provider-interface.test.ts` | 95% | +| `provider-registry.ts` | `api/lib/__tests__/provider-registry.test.ts` | 95% | +| `transaction-logger.ts` | `api/lib/__tests__/transaction-logger.test.ts` | 90% | +| `webhook-handler.ts` | `api/lib/__tests__/webhook-handler.test.ts` | 90% | +| `mpesa-provider.ts` | `api/lib/__tests__/mpesa-provider.test.ts` | 95% | +| `currency.ts` (frontend) | `src/lib/__tests__/currency.test.ts` | 95% | + +--- + +## Phase 2: Migration (M-Pesa Refactoring & M-Pesa KES Lock) + +### Goal +Migrate all existing M-Pesa functionality to the new provider framework, enforce KES-only locking, and achieve full backward compatibility with zero regression. + +### 2.1 Backend Router Migration + +#### 2.1.1 New Wallet Router + +**File:** `api/wallet-router.ts` (replaces `api/mpesa-router.ts`) + +| Procedure | Method | Purpose | Provider-Scoped? | +|---|---|---|---| +| `transactions.list` | query | List wallet transactions with provider/location/date/status filters | Yes | +| `transactions.stats` | query | Aggregated stats by provider and currency | Yes | +| `transactions.importSms` | mutation | Parse and import SMS for SMS-capable providers | Yes (validates provider) | +| `transactions.tagToSupplier` | mutation | Link transaction to supplier | Provider-agnostic | +| `transactions.createExpenseFromTxn` | mutation | Create expense from wallet transaction | Provider-agnostic | +| `transactions.linkToAccount` | mutation | Link topup to bank account + wallet | Provider-agnostic | +| `providers.list` | query | List all active providers for a location | — | +| `providers.getConfig` | query | Get provider config for a location | Yes | +| `providers.setDefault` | mutation | Set default wallet provider for location | Yes | +| `dailyLedger.list` | query | List daily wallet ledger entries | Yes (by provider) | +| `dailyLedger.create` | mutation | Upsert daily wallet ledger | Yes (by provider) | +| `reconciliation.list` | query | List reconciliation records | Yes (by provider) | +| `reconciliation.create` | mutation | Create reconciliation record | Yes (by provider) | + +#### 2.1.2 M-PESA Router Deprecation + +Keep `api/mpesa-router.ts` as a thin proxy that delegates to `wallet-router.ts` with `provider = 'mpesa'`: + +```typescript +// Deprecated mpesa-router.ts — delegates to wallet router +export const mpesaRouter = router({ + list: walletRouter.transactions.list, // Scope to mpesa via middleware + stats: walletRouter.transactions.stats, + importSms: walletRouter.transactions.importSms, // Automatically validates provider='mpesa' + // ... etc +}); +``` + +This ensures backward compatibility for any existing tRPC references while all new code uses the unified wallet router. + +#### 2.1.3 Daily Ledger Router Migration + +**File:** `api/daily-ledger-router.ts` → Refactor to delegate to `wallet-router.dailyLedger`: +- Old endpoint continues to work (proxy to wallet router with `provider = 'mpesa'`) +- New unified `dailyLedger.*` procedures available + +#### 2.1.4 Dashboard Router Updates + +**File:** `api/dashboard-router.ts` + +Update M-PESA references in: +- `overview` procedure: Aggregate across all active wallet providers (not just M-PESA) +- `todayPayments` procedure: Include all wallet outflows (not just M-PESA) +- `mpesaFeeAnalysis` → `walletFeeAnalysis`: Multi-provider fee breakdown + +### 2.2 Frontend Migration + +#### 2.2.1 M-PESA Page → Wallet Page + +**File:** `src/pages/Mpesa.tsx` → Partially migrated + +The `/mpesa` route continues to work as M-PESA-specific view. A new `/wallet` route provides the unified multi-provider view: + +- **`/mpesa`**: Shown when user only uses M-PESA or navigates from legacy link. Fetches M-PESA data only. +- **`/wallet`**: Unified dashboard showing all active wallet providers with provider tabs/filtering. + +Key UI changes: +- Provider selector dropdown in transaction list +- Currency column displayed in transaction table +- Provider color-coded badges +- SMS import shows which provider's parser is used +- Stats panel shows per-provider and cross-provider breakdowns + +#### 2.2.2 Accounts Page + +**File:** `src/pages/Accounts.tsx` + +Update account type UI: +- Add `airtel_money` and `sasapay` account types (with brand colors and icons) +- Chart: Show wallet balances grouped by provider +- Account creation: Wire to `mobileWalletProviders` for type validation + +#### 2.2.3 Other Frontend Pages + +Update references across: +| Page | Change | +|---|---| +| `DailyPayments.tsx` | Show wallet payments across all providers, not just M-PESA | +| `Locations.tsx` | `defaultMpesaAccountId` → generic default wallet provider config | +| `Businesses.tsx` | Create default wallet accounts for all active providers (not just M-PESA) | +| `Reports.tsx` | `exportMpesa` → `exportWalletTransactions(provider?)` | +| `ChartOfAccounts.tsx` | Show wallet accounts grouped by provider type | + +### 2.3 M-Pesa Currency Lock Implementation + +#### 2.3.1 Validation Layer + +**File:** `api/lib/mobile-wallet/currency-lock.ts` + +```typescript +// Centralized currency validation for all wallet providers +export function validateProviderCurrency( + provider: string, + currency: string, + providers: Map +): { valid: boolean; error?: string; suggestedAction?: string } { + const providerInstance = providers.get(provider); + if (!providerInstance) { + return { valid: false, error: `Unknown provider: ${provider}` }; + } + + if (!providerInstance.supportedCurrencies.includes(currency)) { + return { + valid: false, + error: `${providerInstance.displayName} only supports ${providerInstance.supportedCurrencies.join(', ')}. ${currency} transactions cannot be processed through this provider.`, + suggestedAction: `Convert ${currency} to ${providerInstance.supportedCurrencies[0]} before initiating payment through ${providerInstance.displayName}.`, + }; + } + + return { valid: true }; +} + +// Auto-conversion with disclosure +export async function ensureProviderCurrency( + amount: Decimal, + fromCurrency: string, + toCurrency: string, + converter: CurrencyConverter +): Promise<{ + originalAmount: Decimal; + originalCurrency: string; + convertedAmount: Decimal; + convertedCurrency: string; + rate: Decimal; + fee?: Decimal; + disclosure: string; +}> { + if (fromCurrency === toCurrency) { + return { + originalAmount: amount, + originalCurrency: fromCurrency, + convertedAmount: amount, + convertedCurrency: toCurrency, + rate: d(1), + disclosure: `No conversion needed — transaction is already in ${toCurrency}.`, + }; + } + + const { converted, rate, fee } = await converter.convert(amount, fromCurrency, toCurrency); + const feeDisplay = fee ? `A conversion fee of ${fee.toFixed(2)} ${fromCurrency} applies.` : ''; + + return { + originalAmount: amount, + originalCurrency: fromCurrency, + convertedAmount: converted, + convertedCurrency: toCurrency, + rate, + fee, + disclosure: `Your ${fromCurrency} ${amount.toFixed(2)} will be converted at ${rate.toFixed(6)} to ${converted.toFixed(2)} ${toCurrency}. ${feeDisplay}`, + }; +} +``` + +#### 2.3.2 Frontend Currency Lock UX + +- When user selects M-PESA as payment method with non-KES currency: + - Show conversion disclosure modal with rate, converted amount, and any fees + - Require explicit confirmation before proceeding + - If no conversion enabled, block with error message: "M-PESA only supports KES transactions. Please convert your funds or use a different provider." + +### 2.4 Business Reset Updates + +**File:** `api/lib/business-reset.ts` + +Update to reset new tables: `mobile_wallet_transactions`, `mobile_wallet_daily_ledger`, `mobile_wallet_reconciliation` +- Preserve `mobile_wallet_providers` and `supported_currencies` (system-level, not tenant data) +- Preserve `provider_configs` (config structure, reset credentials) + +### 2.5 Tests (Phase 2) + +| Module | Test File | Coverage Target | +|---|---|---| +| `wallet-router.ts` | `api/__tests__/wallet-router.test.ts` | 90% | +| M-PESA backward compat | `api/__tests__/mpesa-router-backward-compat.test.ts` | 95% | +| Currency lock validation | `api/lib/__tests__/currency-lock.test.ts` | 95% | +| Frontend Wallet page | `src/pages/__tests__/Wallet.test.tsx` | 85% | +| Mpesa→Wallet migration script | `scripts/__tests__/migrate-mpesa.test.ts` | 90% | + +### 2.6 End-to-End Testing + +- Verify all existing M-PESA flows (list, import SMS, create expense, link topup, daily ledger, reconciliation) work identically after migration +- Verify KES-only lock: attempt to import non-KES M-PESA SMS → blocked with error +- Verify cross-provider aggregation in dashboard +- Verify all routes continue to function (no 404 due to router refactoring) + +--- + +## Phase 3: Scale (Airtel Money & Sasapay Integration) + +### Goal +Integrate Airtel Money and Sasapay as first-class providers, complete UAT, and deploy with phased rollout. + +### 3.1 Airtel Money Provider + +**File:** `api/lib/mobile-wallet/providers/airtel-money-provider.ts` + +```typescript +export class AirtelMoneyProvider extends BaseWalletProvider { + readonly code = 'airtel_money'; + readonly displayName = 'Airtel Money'; + readonly supportedCurrencies = ['KES', 'UGX', 'TZS', 'MWK', 'ZMW', 'RWF']; + readonly features = { + initiatePayment: false, // SMS-based in Phase 3 (MVP) + queryStatus: false, + processWebhook: false, + refund: false, + balanceInquiry: false, + smsImport: true, // SMS parser for Airtel Money messages + }; + + async parseSms(text: string): Promise { + // Airtel Money SMS format patterns: + // "You have received UGX 50,000 from 2567XX XXX XXX" + // " UGX 5,000 sent to 2567XX XXX XXX. Airtel Money balance: UGX 20,000" + // etc. + } +} +``` + +**SMS parser patterns for Airtel Money:** +| Pattern | Detection | Direction | Type | +|---|---|---|---| +| "You have received [CURRENCY] [amount] from [sender]" | `includes("you have received")` | `in` | `payment` | +| "[CURRENCY] [amount] sent to [recipient]" | `includes(" sent to ")` | `out` | `transfer` | +| "[CURRENCY] [amount] withdrawn" | `includes("withdrawn")` | `out` | `withdrawal` | +| Airtel Money cash power purchase | `includes("cashpower")` | `out` | `utility` | + +**Key difference from M-PESA:** Airtel Money operates in multiple East African currencies (KES, UGX, TZS, MWK, ZMW, RWF). The parser must detect the currency code from the SMS text. + +### 3.2 Sasapay Provider + +**File:** `api/lib/mobile-wallet/providers/sasapay-provider.ts` + +```typescript +export class SasapayProvider extends BaseWalletProvider { + readonly code = 'sasapay'; + readonly displayName = 'Sasapay'; + readonly supportedCurrencies = ['KES']; + readonly features = { + initiatePayment: true, // Sasapay has a REST API + queryStatus: true, + processWebhook: true, // Callback/webhook support + refund: true, + balanceInquiry: true, + smsImport: false, + }; + + // Sasapay API configuration + private baseUrl: string; + private apiKey: string; + private apiSecret: string; + + constructor(config: { baseUrl: string; apiKey: string; apiSecret: string }) { + super(); + this.baseUrl = config.baseUrl; + this.apiKey = config.apiKey; + this.apiSecret = config.apiSecret; + } + + async initiatePayment(request: WalletTransactionRequest): Promise { + this.validateCurrency(request.currency); + // POST /api/v1/payments + // { amount, currency, phone, reference, description } + // Returns { transactionId, status, ... } + // Map to standardized WalletTransactionResult + } + + async queryStatus(providerTxnId: string): Promise { + // GET /api/v1/payments/{id}/status + } + + async processWebhook(payload: WalletWebhookPayload): Promise { + // 1. Verify HMAC signature from headers + // 2. Parse JSON payload + // 3. Return standardized result + } + + async processRefund(providerTxnId: string, amount?: string): Promise { + // POST /api/v1/payments/{id}/refund + } + + async balanceInquiry(accountId: number): Promise { + // GET /api/v1/account/balance + } +} +``` + +**Sasapay-specific features:** +- REST API with API key + secret authentication +- Webhooks with HMAC signature verification +- Supports both C2B (customer to business) and B2C (business to customer) payments +- Real-time transaction status polling +- Refund support via API + +### 3.3 Unified Payment Selection UI + +**File:** `src/components/WalletPaymentSelector.tsx` + +```typescript +// A reusable component that shows all active wallet providers +// Used in: Expenses, Bills, Payroll, DailySales +interface WalletPaymentSelectorProps { + value: string; // Selected provider code + onChange: (code: string) => void; + currency: string; // Current transaction currency + amount: string; // For fee disclosure + className?: string; +} + +// Features: +// - Shows all active providers for the current location +// - Provider cards with brand color, logo, supported currencies +// - Disables providers that don't support the current currency +// - Shows fee/processing time disclosures per provider +// - Indicates default provider with badge +``` + +### 3.4 Admin Dashboard for Wallet Monitoring + +**File:** `src/pages/WalletAdmin.tsx` or embedded in existing Settings/Admin + +Features: +- Multi-provider transaction feed with real-time filtering +- Provider health status (last successful transaction per provider) +- Daily settlement summaries by provider +- Reconciliation tool: match provider statements against system records +- Provider configuration: activate/deactivate, set API keys, default provider + +### 3.5 Provider Configuration API + +**File:** `api/wallet-management-router.ts` (admin-only, permission-protected) + +| Procedure | Method | Purpose | +|---|---|---| +| `providers.configure` | mutation | Set API keys, endpoints, webhook URLs for a provider at a location | +| `providers.testConnection` | mutation | Test provider API connectivity | +| `providers.activate` | mutation | Enable/disable a provider for a location | +| `rates.manualUpdate` | mutation | Manually set exchange rate | +| `rates.sync` | mutation | Force sync rates from external provider | +| `rates.history` | query | View exchange rate history | + +### 3.6 Multi-Currency Conversion UI + +**File:** `src/components/CurrencyConverterDialog.tsx` + +A dialog component for manual currency conversion: +- Select from currency (auto-detected from transaction) +- Select to currency (defaults to provider's supported currency) +- Shows live exchange rate with last-updated timestamp +- Shows converted amount +- Shows conversion fee if applicable +- "Apply Conversion" button with confirmation + +### 3.7 Tests (Phase 3) + +| Module | Test File | Coverage Target | +|---|---|---| +| `airtel-money-provider.ts` | `api/lib/__tests__/airtel-money-provider.test.ts` | 90% | +| `sasapay-provider.ts` | `api/lib/__tests__/sasapay-provider.test.ts` | 90% | +| `WalletPaymentSelector.tsx` | `src/components/__tests__/WalletPaymentSelector.test.tsx` | 85% | +| `WalletAdmin.tsx` | `src/pages/__tests__/WalletAdmin.test.tsx` | 80% | +| Airtel Money SMS parser | `api/lib/__tests__/airtel-sms-parser.test.ts` | 95% | +| Unified webhook (Sasapay) | `api/__tests__/wallet-webhook.test.ts` | 90% | +| End-to-end wallet cycle | `e2e/__tests__/wallet-cycle.test.ts` | Coverage of primary flows | + +--- + +## Implementation Order & Dependencies + +``` +Phase 1: Foundation +├── 1.1 supported_currencies + exchange_rates tables [DB migration] +├── 1.2 currency-converter.ts + exchange-rate-sync.ts [Core service] +├── 1.3 mobile_wallet_providers + mobile_wallet_transactions + etc. [DB migration] +├── 1.4 provider-interface.ts + provider-registry.ts [Abstract layer] +├── 1.5 transaction-logger.ts [Unified logging] +├── 1.6 webhook-handler.ts [Webhook router] +├── 1.7 Permissions update [Backend + frontend] +├── 1.8 Frontend: currency.ts + const.ts [Utilities] +├── 1.9 M-PESA provider class (parseSms, generateSmsPreview) [Concrete provider] +└── 1.10 Unit tests [Coverage 90%+] + +Phase 2: Migration +├── 2.1 Data migration: mpesaTransactions → mobile_wallet_transactions [Script] +├── 2.2 Data migration: dailyMpesaLedger → mobile_wallet_daily_ledger [Script] +├── 2.3 Data migration: mpesaReconciliation → mobile_wallet_reconciliation [Script] +├── 2.4 Data migration: locations.defaultMpesaAccountId → provider_configs [Script] +├── 2.5 wallet-router.ts creation (replaces mpesa-router.ts) [Backend] +├── 2.6 mpesa-router.ts → proxy pattern [Backward compat] +├── 2.7 daily-ledger-router.ts → proxy pattern [Backward compat] +├── 2.8 dashboard-router.ts → multi-provider aggregation [Backend] +├── 2.9 Currency lock validation layer [Core service] +├── 2.10 Frontend: /wallet page (replaces /mpesa page) [Frontend] +├── 2.11 Frontend: Legacy /mpesa route preserved [Frontend] +├── 2.12 Frontend: Accounts, DailyPayments, Locations updates [Frontend] +├── 2.13 Business reset updates [Core service] +├── 2.14 Backward compatibility tests + E2E [Testing] +└── 2.15 Unit tests (wallet router, currency lock, migration) [Testing] + +Phase 3: Scale +├── 3.1 Airtel Money provider + SMS parser [Provider] +├── 3.2 Sasapay provider + REST API + webhook handling [Provider] +├── 3.3 Frontend: WalletPaymentSelector.tsx [Component] +├── 3.4 Frontend: CurrencyConverterDialog.tsx [Component] +├── 3.5 Admin dashboard for wallet monitoring [Frontend] +├── 3.6 Wallet management API (admin router) [Backend] +├── 3.7 Full UAT in staging environment [Testing] +├── 3.8 Performance/load testing [Testing] +├── 3.9 Production deployment: phased rollout [DevOps] +└── 3.10 Post-deployment monitoring + documentation [Operations] +``` + +--- + +## Success Criteria Checklist + +| Criterion | Measurement | Target | +|---|---|---| +| All existing M-PESA functionality operational post-migration | E2E test pass rate | 100% | +| Multi-currency conversion accuracy | Rate comparison vs source | 99.9% | +| New provider integration time | Days from template to production | < 5 business days | +| Zero-downtime deployment | Rolling update strategy | Confirmed | +| Unit test coverage (new modules) | Vitest coverage report | ≥ 90% | +| Backward compatibility | Old tRPC endpoints still work | All pass | +| M-PESA KES lock enforcement | Test: non-KES txns blocked | 100% | +| Cross-provider dashboard aggregation | Test: sums accurate across providers | Verified | + +--- + +## Key Design Decisions & Rationale + +| Decision | Rationale | +|---|---| +| **Store original + base currency amounts** | Enables accurate cross-provider/currency reporting without losing traceability to original transaction | +| **SMS parser stays provider-specific** | Each provider has unique SMS format; parser TDD per provider makes testing and maintenance easier | +| **Abstract class over interface** | Shared utilities (`parseDecimal`, `validateCurrency`, `logError`) reduce boilerplate across providers | +| **Provider registry as singleton** | Single source of truth for all active providers; simplifies dependency injection in routers | +| **M-PESA router → proxy pattern** | Zero risk of breaking existing API consumers; gradual migration path | +| **`numeric(18, 8)` for exchange rates** | Sufficient precision for all African currency pairs (KES→USD ~0.0075 requires high precision) | +| **`Intl.NumberFormat` for frontend formatting** | Native browser API handles all locale-specific formatting (symbol position, decimal/group separators) | +| **Per-provider `supportedCurrencies`** | Enables currency constraint validation at the provider level without hardcoding | From 36498fd01bee35a0d26b638fe1765de28e5bd525 Mon Sep 17 00:00:00 2001 From: Martin Bhuong Date: Tue, 19 May 2026 08:57:52 +0300 Subject: [PATCH 2/2] fix: update payment methods link and tab support update the payment methods link in DailySales page to point to the accounts page with tab parameter, and add query parameter handling in Accounts page to set the active tab on page load --- src/pages/Accounts.tsx | 9 ++++++++- src/pages/DailySales.tsx | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/pages/Accounts.tsx b/src/pages/Accounts.tsx index f161e4c..ec10809 100644 --- a/src/pages/Accounts.tsx +++ b/src/pages/Accounts.tsx @@ -27,16 +27,23 @@ export function Accounts() { const canManage = hasPermission(user?.role ?? "viewer", PERMISSIONS.ACCOUNTS_MANAGE); const [searchParams, setSearchParams] = useSearchParams(); const sectionParam = searchParams.get("section"); + const tabParam = searchParams.get("tab"); const [section, setSection] = useState<"accounts" | "chart-of-accounts" | "journal-entries">( sectionParam === "chart-of-accounts" || sectionParam === "journal-entries" ? sectionParam : "accounts" ); - const [tab, setTab] = useState<"accounts" | "payment-methods">("accounts"); + const [tab, setTab] = useState<"accounts" | "payment-methods">( + tabParam === "payment-methods" ? "payment-methods" : "accounts" + ); useEffect(() => { const sp = searchParams.get("section"); if (sp === "chart-of-accounts" || sp === "journal-entries") { setSection(sp); } + const tp = searchParams.get("tab"); + if (tp === "payment-methods") { + setTab("payment-methods"); + } }, [searchParams]); const handleSectionChange = (newSection: "accounts" | "chart-of-accounts" | "journal-entries") => { diff --git a/src/pages/DailySales.tsx b/src/pages/DailySales.tsx index 3cfb12a..f7541bb 100644 --- a/src/pages/DailySales.tsx +++ b/src/pages/DailySales.tsx @@ -251,7 +251,7 @@ export function DailySales() { )} {selectedLocation && (!locPaymentMethods || locPaymentMethods.length === 0) && (
- No payment methods tagged to this branch. Go to Payment Methods to set them up. + No payment methods tagged to this branch. Go to Payment Methods to set them up.
)}