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 a Kenyan developer building fintech tools, you've probably asked yourself: *How do we actually track M-Pesa transactions at scale without destroying our database?*\n\nM-Pesa processes over $1.2 billion in daily transactions across Kenya. For SMEs, this volume is both a blessing and a nightmare—they get paid instantly, but tracking those payments for accounting becomes a manual, error-prone hell.\n\nThis is the problem MpesaBooks solves. But the real story isn't about the UI. It's about how we designed the backend to handle real-time M-Pesa reconciliation on Africa's mobile networks.\n\nLet's talk architecture.\n\n## The Problem: Why Traditional Accounting Doesn't Work for M-Pesa\n\nM-Pesa's strength—instant settlement across Africa's largest telecom network—becomes a liability for SMEs trying to reconcile accounts.\n\nHere's what happens:\n\n1. **Real-time transactions arrive asynchronously** — Your customer sends payment at 2:14 PM. The SMS arrives at 2:14:03 PM. Your webhook fires at 2:14:07 PM. Meanwhile, your business logic is running somewhere else.\n\n2. **Network uncertainty** — Kenya's 4G coverage is ~85% in urban areas, but drops to 30% in rural zones. Webhooks fail. SMS gets delayed. Transactions get duplicated.\n\n3. **Manual reconciliation is death by a thousand cuts** — Most SMEs still download CSV files from M-Pesa, manually enter them into Excel, then into QuickBooks. When you process 50+ transactions daily, this becomes your full-time job.\n\n4. **Accounting complexity** — Is this revenue or a refund? Who is this customer? Did they already pay? Which invoice is this for?\n\nMost Kenyan businesses solve this with **spreadsheets and prayers**. We decided to solve it with **event-driven architecture and idempotent APIs**.\n\n## Architecture Layer 1: API Design for Reliability\n\nThe first principle: **every M-Pesa API call must be idempotent**.\n\nHere's our transaction ingestion endpoint:\n\n```

typescript\n// POST /api/v1/transactions/ingest\n// Purpose: Accept M-Pesa transaction data from multiple sources\n// Sources: Safaricom Daraja API, SMS parsing, M-Pesa B2B API\n\ninterface MpesaTransactionPayload {\n  mpesaReceiptNumber: string; // Unique ID: e.g., \"NLJ7RF2QH3\"\n  phoneNumber: string;\n  amount: number;\n  timestamp: ISO8601;\n  senderName: string;\n  transactionType: 'payment' | 'refund' | 'reversal';\n  source: 'daraja' | 'sms_webhook' | 'ussd';\n}\n\n@Post('/ingest')\nasync ingestTransaction(@Body() payload: MpesaTransactionPayload) {\n  const idempotencyKey = `${payload.mpesaReceiptNumber}-${payload.source}`;\n  \n  // Check if we've already processed this\n  const existing = await db.transactions.findOne({ \n    idempotencyKey \n  });\n  \n  if (existing) {\n    return { success: true, transaction: existing };\n  }\n  \n  // Validate against M-Pesa's unique receipt number\n  const isValid = await validateWithSafaricom(payload.mpesaReceiptNumber);\n  if (!isValid) {\n    throw new InvalidTransactionError();\n  }\n  \n  // Atomically insert the transaction\n  const transaction = await db.transactions.insertOne({\n    ...payload,\n    idempotencyKey,\n    createdAt: new Date(),\n    status: 'pending_reconciliation',\n  });\n  \n  // Emit event for async processing\n  await eventBus.emit('transaction.received', transaction);\n  \n  return { success: true, transaction };\n}\n

```\n\nWhy idempotency matters: If our webhook handler crashes after processing but before responding, Safaricom will retry. Without idempotent keys, we'd create duplicate transactions. With them, the second attempt returns the same result.\n\n## Architecture Layer 2: Database Design for African Networks\n\nWe don't use a single monolithic database. We use **event sourcing** with a **write-optimized store** and **read-optimized views**.\n\n```

javascript\n// Database Schema: PostgreSQL with Partitioning\n\nCREATE TABLE transactions (\n  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n  idempotency_key VARCHAR(255) UNIQUE NOT NULL,\n  mpesa_receipt_number VARCHAR(20) UNIQUE NOT NULL,\n  phone_number VARCHAR(13) NOT NULL,\n  amount DECIMAL(10, 2) NOT NULL,\n  currency VARCHAR(3) DEFAULT 'KES',\n  timestamp TIMESTAMP NOT NULL,\n  sender_name VARCHAR(255),\n  transaction_type VARCHAR(20) CHECK(transaction_type IN ('payment', 'refund', 'reversal')),\n  source VARCHAR(20),\n  status VARCHAR(50) DEFAULT 'pending_reconciliation',\n  created_at TIMESTAMP DEFAULT NOW(),\n  updated_at TIMESTAMP DEFAULT NOW(),\n  INDEX idx_phone_timestamp (phone_number, timestamp),\n  INDEX idx_mpesa_receipt (mpesa_receipt_number),\n  INDEX idx_status (status)\n) PARTITION BY RANGE (YEAR(timestamp));\n\n// Denormalized View for Daily Reporting (updated hourly)\nCREATE MATERIALIZED VIEW daily_reconciliation AS\nSELECT
Enter fullscreen mode Exit fullscreen mode

Top comments (0)