DEV Community

sharkflow ltd
sharkflow ltd

Posted on

MpesaBooks — devto

{
  "title": "How to Track M-Pesa Transactions for Business: The Technical Architecture Behind MpesaBooks",
  "content": "# How to Track M-Pesa Transactions for Business: The Technical Architecture Behind MpesaBooks\n\nIf you're running an SME in Kenya, you've probably asked yourself: *\"How do I actually track M-Pesa money without losing my mind in a spreadsheet?\"*\n\nSharkFlow's MpesaBooks solves exactly this problem—but the real story is *how* we built it to handle Kenya's chaotic, beautiful reality of mobile money. Let me walk you through the technical decisions that make it work.\n\n## The Problem We Started With\n\nKenya has 60M+ mobile money users, and M-Pesa processes $320B annually. Yet most SMEs still use:\n- SMS screenshots in WhatsApp groups\n- Manual Excel entries (good luck with reconciliation)\n- Bank statements they check once a month\n\nScaling accounting software for this reality means building for:\n- **Unreliable networks** (3G/EDGE in rural areas)\n- **Low-end devices** (budget Android phones with 1GB RAM)\n- **High transaction volume** (a kiosk owner doing 200+ transactions/day)\n- **Zero latency tolerance** (Kenyan traders need instant reconciliation)\n\n## API Design: Real-Time vs. Batch Sync\n\nWe started with a naive approach: poll Safaricom's M-Pesa API every 30 seconds. It failed immediately.\n\nHere's why: a single kiosk owner in Nairobi's informal sector does ~150 transactions/day. Polling every 30 seconds meant 2,880 API calls *per location per day*. Scale that to 10,000 SMEs and you're looking at 28.8M API calls daily. Safaricom's rate limits? 50 calls/minute. Dead on arrival.\n\n**Solution: Hybrid pull-push architecture.**\n\n```

javascript\n// Our transaction sync service\nconst mpesaTransactionSync = async (accountId) => {\n  // 1. Check for webhook callbacks (push)\n  const webhookTransactions = await db.webhooks\n    .find({ accountId, processed: false })\n    .lean();\n\n  // 2. Only poll API for missing transactions (pull)\n  const lastSyncTime = await getLastSyncTimestamp(accountId);\n  const apiTransactions = await safaricomAPI.queryTransactions({\n    accountId,\n    startDate: lastSyncTime,\n    limit: 100 // Small batch to respect rate limits\n  });\n\n  // 3. Deduplicate and merge\n  const merged = deduplicateByTransactionId([\n    ...webhookTransactions,\n    ...apiTransactions\n  ]);\n\n  // 4. Reconcile with local state\n  const reconciled = await reconcileWithLedger(merged);\n\n  return reconciled;\n};\n

```\n\nWebhooks give us real-time updates; polling handles edge cases when callbacks fail (and they do, on African networks).\n\n## Database Architecture: The Ledger Problem\n\nAccounting software lives or dies by its ledger design. We use a **double-entry ledger model** but optimized for M-Pesa's unique properties:\n\n```

javascript\n// Schema: Transactions are immutable, ledger entries are append-only\ndb.createCollection('transactions', {\n  validator: {\n    $jsonSchema: {\n      bsonType: 'object',\n      required: ['transactionId', 'mpesaRef', 'amount', 'timestamp'],\n      properties: {\n        _id: { bsonType: 'objectId' },\n        accountId: { bsonType: 'string' }, // SME identifier\n        transactionId: { bsonType: 'string', unique: true }, // Safaricom's unique ID\n        mpesaRef: { bsonType: 'string', index: true }, // User-visible receipt\n        amount: { bsonType: 'decimal128' }, // Never use float for money\n        type: { enum: ['receive', 'send', 'withdrawal'] },\n        counterparty: { bsonType: 'string' }, // Who paid/received\n        timestamp: { bsonType: 'date', index: true },\n        status: { enum: ['pending', 'confirmed', 'reversed'] },\n        ledgerEntries: { bsonType: 'array' }, // Links to accounting entries\n        metadata: { bsonType: 'object' } // Tags, invoice refs, etc.\n      }\n    }\n  }\n});\n\ndb.createCollection('ledger', {\n  validator: {\n    $jsonSchema: {\n      bsonType: 'object',\n      required: ['accountId', 'account', 'amount', 'timestamp'],\n      properties: {\n        _id: { bsonType: 'objectId' },\n        accountId: { bsonType: 'string' },\n        account: { enum: ['cash', 'revenue', 'expense', 'payable', 'receivable'] },\n        amount: { bsonType: 'decimal128' },\n        debit: { bsonType: 'boolean' },\n        transactionRef: { bsonType: 'objectId' },\n        timestamp: { bsonType: 'date', index: true },\n        balanceSnapshot: { bsonType: 'decimal128' } // Cache for speed\n      }\n    }\n  }\n});\n

Enter fullscreen mode Exit fullscreen mode


\n\n*Why MongoDB?* We chose it because:\n1. Flexible schema for different transaction types (C2B, B2B, M-Pesa to bank)\n2. Horizontal scaling via sharding by accountId (critical for Kenya's distributed SMEs)\n3. TTL indexes for auto-archiving old data (storage is expensive in Africa)\n

Top comments (0)