Skip to content

Payments Architecture

How money flows through Yebo.


Overview

All money in Yebo flows through YeboSafe:

  • User wallets
  • Mobile money in/out
  • Escrow for transactions
  • Internal transfers

System Architecture

┌────────────────────────────────────────────────────────────────┐
│                      MOBILE MONEY                               │
│   M-Pesa    •    MTN MoMo    •    Airtel    •    EcoCash       │
└──────────────────────────┬─────────────────────────────────────┘


┌────────────────────────────────────────────────────────────────┐
│                    PAYMENT GATEWAY                              │
│   Deposits (STK Push)  •  Withdrawals (B2C)  •  Webhooks       │
└──────────────────────────┬─────────────────────────────────────┘


┌────────────────────────────────────────────────────────────────┐
│                      YEBOSAFE                                   │
│   Wallets  •  Transactions  •  Escrows  •  Ledger              │
└──────────────────────────┬─────────────────────────────────────┘

         ┌─────────────────┼─────────────────┐
         ▼                 ▼                 ▼
    ┌─────────┐       ┌─────────┐       ┌─────────┐
    │YeboShops│       │YeboJobs │       │ YeboNa  │
    │ Escrow  │       │ Payroll │       │ Trade   │
    └─────────┘       └─────────┘       └─────────┘

Wallet System

One Wallet Per User Per Currency

sql
CREATE TABLE wallets (
  id UUID PRIMARY KEY,
  user_id UUID NOT NULL,
  currency VARCHAR(3) NOT NULL,  -- KES, NGN, USD
  balance DECIMAL(15,2) DEFAULT 0,
  available_balance DECIMAL(15,2) DEFAULT 0,  -- minus pending
  created_at TIMESTAMP
);

Balance Types

TypeDescription
balanceTotal funds
available_balanceMinus pending withdrawals/escrows
pending_inDeposits not yet confirmed
pending_outWithdrawals not yet confirmed

Transaction Ledger

Every money movement is a transaction:

sql
CREATE TABLE transactions (
  id UUID PRIMARY KEY,
  wallet_id UUID NOT NULL,
  type VARCHAR(20) NOT NULL,
  amount DECIMAL(15,2) NOT NULL,
  balance_before DECIMAL(15,2),
  balance_after DECIMAL(15,2),
  reference VARCHAR(100),
  metadata JSONB,
  status VARCHAR(20),
  created_at TIMESTAMP
);

Transaction Types

TypeDescriptionDirection
depositMobile money in+
withdrawalMobile money out-
escrow_holdHold for transaction-
escrow_releaseRelease to seller+
escrow_refundRefund to buyer+
transfer_outSend to another user-
transfer_inReceive from user+
feePlatform fee-
refundDispute refund+

Mobile Money Integration

M-Pesa (Kenya)

Deposit (STK Push):

javascript
async function initiateDeposit(walletId, amount, phone) {
  // 1. Create pending transaction
  const tx = await db.transactions.create({
    walletId,
    type: 'deposit',
    amount,
    status: 'pending'
  });
  
  // 2. Initiate STK Push
  const mpesa = await daraja.stkPush({
    phoneNumber: phone,
    amount,
    accountReference: tx.id,
    transactionDesc: 'Yebo Wallet Deposit'
  });
  
  // 3. Return pending status
  return { transactionId: tx.id, status: 'pending', checkoutRequestId: mpesa.CheckoutRequestID };
}

// Webhook handler
async function handleMpesaCallback(data) {
  const { ResultCode, CheckoutRequestID } = data.Body.stkCallback;
  
  const tx = await db.transactions.findByCheckoutId(CheckoutRequestID);
  
  if (ResultCode === 0) {
    // Success
    await db.transactions.update(tx.id, { status: 'completed' });
    await db.wallets.increment(tx.walletId, tx.amount);
    await notifyUser(tx.userId, `KES ${tx.amount} deposited!`);
  } else {
    // Failed
    await db.transactions.update(tx.id, { status: 'failed' });
  }
}

Withdrawal (B2C):

javascript
async function initiateWithdrawal(walletId, amount, phone) {
  const wallet = await db.wallets.get(walletId);
  
  // 1. Check balance
  if (wallet.available_balance < amount) {
    throw new Error('Insufficient balance');
  }
  
  // 2. Create pending transaction and deduct
  const tx = await db.transactions.create({
    walletId,
    type: 'withdrawal',
    amount: -amount,
    status: 'pending'
  });
  await db.wallets.decrement(walletId, amount);
  
  // 3. Initiate B2C
  const mpesa = await daraja.b2c({
    phoneNumber: phone,
    amount,
    occasion: tx.id
  });
  
  return { transactionId: tx.id, status: 'processing' };
}

Escrow System

Flow

BUYER PAYS


┌─────────────────────────────────────────┐
│           ESCROW CREATED                 │
│   buyer_wallet: -amount                  │
│   escrow: +amount                        │
└─────────────────────────────────────────┘


SELLER SHIPS


BUYER CONFIRMS


┌─────────────────────────────────────────┐
│           ESCROW RELEASED                │
│   escrow: -amount                        │
│   seller_wallet: +(amount - fee)         │
│   platform: +fee                         │
└─────────────────────────────────────────┘

States

CREATED ──► FUNDED ──► SHIPPED ──► DELIVERED ──► RELEASED
                  │         │
                  │         └──► DISPUTED ──► RESOLVED

                  └──► CANCELLED ──► REFUNDED

Implementation

javascript
async function createEscrow(orderId, buyerWalletId, sellerWalletId, amount) {
  const buyerWallet = await db.wallets.get(buyerWalletId);
  
  if (buyerWallet.available_balance < amount) {
    throw new Error('Insufficient balance');
  }
  
  // Atomic transaction
  return await db.transaction(async (tx) => {
    // Hold from buyer
    await tx.wallets.decrement(buyerWalletId, amount);
    await tx.transactions.create({
      walletId: buyerWalletId,
      type: 'escrow_hold',
      amount: -amount,
      reference: orderId
    });
    
    // Create escrow record
    const escrow = await tx.escrows.create({
      orderId,
      buyerWalletId,
      sellerWalletId,
      amount,
      status: 'funded'
    });
    
    return escrow;
  });
}

async function releaseEscrow(escrowId) {
  const escrow = await db.escrows.get(escrowId);
  
  if (escrow.status !== 'funded') {
    throw new Error('Escrow not in releasable state');
  }
  
  const fee = escrow.amount * 0.05;  // 5% fee
  const sellerAmount = escrow.amount - fee;
  
  return await db.transaction(async (tx) => {
    // Release to seller
    await tx.wallets.increment(escrow.sellerWalletId, sellerAmount);
    await tx.transactions.create({
      walletId: escrow.sellerWalletId,
      type: 'escrow_release',
      amount: sellerAmount,
      reference: escrow.orderId
    });
    
    // Collect fee
    await tx.wallets.increment(PLATFORM_WALLET_ID, fee);
    
    // Update escrow
    await tx.escrows.update(escrowId, {
      status: 'released',
      releasedAt: new Date()
    });
  });
}

Multi-Currency

Supported Currencies

CurrencyCountries
KESKenya
NGNNigeria
GHSGhana
TZSTanzania
UGXUganda
USDCross-border

Conversion

javascript
async function convert(amount, fromCurrency, toCurrency) {
  if (fromCurrency === toCurrency) return amount;
  
  const rate = await getExchangeRate(fromCurrency, toCurrency);
  return amount * rate;
}

// Example: KES to USD
// const usd = await convert(10000, 'KES', 'USD');
// => ~77 (at 130 KES/USD)

Security

Fraud Prevention

  • Velocity limits: Max transactions per hour
  • Amount limits: Per transaction and daily
  • Device fingerprinting: Flag new devices
  • Anomaly detection: Unusual patterns

Reconciliation

javascript
// Daily reconciliation job
async function reconcile() {
  const wallets = await db.wallets.findAll();
  
  for (const wallet of wallets) {
    const calculatedBalance = await db.transactions.sum({
      walletId: wallet.id,
      status: 'completed'
    });
    
    if (calculatedBalance !== wallet.balance) {
      await alertOps('Balance mismatch', { wallet, calculated, actual: wallet.balance });
    }
  }
}

Reporting

Available Reports

  • Transaction history
  • Daily summary
  • Fee collection
  • Escrow status
  • Mobile money success rates

Dashboard Metrics

MetricDescription
GMVGross merchandise value
TPVTotal payment volume
Success rateSuccessful transactions %
Avg transactionAverage transaction size

One chat. Everything done.