# B2C Loan Disbursement Flow Documentation

## Current Disbursement Steps (Before PIN Enhancement)

```
1. Admin clicks "Disburse" on approved loan in admin panel
   └─> POST /admin/loans/{loan_id}/disburse
   
2. LoanController::disburse() checks:
   - Loan status is 'approved'
   - Loan has user with M-Pesa phone number
   - Sets disbursement_date if not set
   
3. LoanService::disburseLoan() creates pending transaction:
   - Generates unique OriginatorConversationID (UUID)
   - Creates Transaction record with status='pending'
   - Stores OriginatorConversationID in mpesa_code field
   
4. MpesaService::sendB2cPayment() builds payload:
   {
     "OriginatorConversationID": "37fe42b6-786e-4465-8d92-bcf99cc094e5",
     "InitiatorName": "testapi",
     "SecurityCredential": "...encrypted...",
     "CommandID": "BusinessPayment",
     "Amount": 10.00,
     "PartyA": "174379",
     "PartyB": "254708374149",
     "Remarks": "Loan disbursement",
     "QueueTimeOutURL": "https://flawed-exactly-jerrell.ngrok-free.dev/api/payment/b2c/timeout",
     "ResultURL": "https://flawed-exactly-jerrell.ngrok-free.dev/api/payment/b2c/result",
     "Occasion": "LoanDisbursement"
   }
   
5. MpesaApiService::post() sends to Daraja:
   - URL: https://sandbox.safaricom.co.ke/mpesa/b2c/v3/paymentrequest
   - Headers: "Authorization: Bearer {ACCESS_TOKEN}"
            "Content-Type: application/json"
   
6. Immediate ACK Response:
   {
     "ConversationID": "AG_20260503_010010031cpgqhfjz357",
     "OriginatorConversationID": "37fe42b6-786e-4465-8d92-bcf99cc094e5",
     "ResponseCode": "0",
     "ResponseDescription": "Accept the service request successfully."
   }
   
7. Transaction stored with mpesa_code = OriginatorConversationID
   └─> Ready for async callback processing
```

---

## M-Pesa Transaction Codes

### In Successful Callback

When Safaricom calls `ResultURL` endpoint, the callback contains:

```json
{
  "Result": {
    "ResultCode": 0,
    "ResultDesc": "The service request is processed successfully.",
    "OriginatorConversationID": "37fe42b6-786e-4465-8d92-bcf99cc094e5",
    "ConversationID": "AG_20260503_010010031cpgqhfjz357",
    "TransactionID": "SG632NMUAB",
    "ResultParameters": {
      "ResultParameter": [
        { "Key": "TransactionAmount", "Value": 10 },
        { "Key": "TransactionReceipt", "Value": "SG632NMUAB" },
        { "Key": "ReceiverPartyPublicName", "Value": "254708374149 - JOHN DOE" },
        { "Key": "TransactionCompletedDateTime", "Value": "03.05.2026 14:30:45" },
        { "Key": "B2CUtilityAccountAvailableFunds", "Value": 8959269.60 }
      ]
    }
  }
}
```

**Transaction codes extracted:**
- `TransactionID` = `SG632NMUAB` (unique M-Pesa receipt)
- `ConversationID` = `AG_20260503_010010031cpgqhfjz357` (Safaricom tracking ID)

**Current code stores:**
```php
$payload = [
    'mpesa_transaction_id' => $result['TransactionID'] ?? null,  // SG632NMUAB
    'mpesa_code' => $result['ConversationID'] ?? $originatorId,   // AG_20260503_...
    'callback_data' => $result,  // Full callback object
];
```

**Database update in Transaction model:**
```
transaction.mpesa_transaction_id = "SG632NMUAB"
transaction.mpesa_code = "AG_20260503_010010031cpgqhfjz357"
transaction.callback_data = { Result: {...} }
transaction.status = "completed"
transaction.processed_at = now()
```

---

## Failure & Delay Handling

### Timeout Scenario

If processing exceeds timeout, Safaricom calls `QueueTimeOutURL`:

```json
{
  "Result": {
    "ResultCode": null,
    "ResultDesc": "The service request is still being processed.",
    "OriginatorConversationID": "37fe42b6-786e-4465-8d92-bcf99cc094e5",
    "ConversationID": "AG_20260503_010010031cpgqhfjz357",
    "TransactionID": null,
    "ReferenceData": {
      "ReferenceItem": {
        "Key": "QueueTimeoutURL",
        "Value": "https://internalsandbox.safaricom.co.ke/mpesa/b2cresults/v1/submit"
      }
    }
  }
}
```

**Current handler** in `MpesaCallbackController::b2cTimeout()`:
```php
$transaction->update([
    'status' => 'failed',  // Marked as failed
    'callback_data' => array_merge($transaction->callback_data ?? [], ['timeout' => $result]),
]);
```

The loan remains in 'approved' status; disbursement not activated.

### Failure Response (E.g., Insufficient Funds)

```json
{
  "Result": {
    "ResultCode": 1,
    "ResultDesc": "The balance is insufficient for the transaction.",
    "OriginatorConversationID": "37fe42b6-786e-4465-8d92-bcf99cc094e5",
    "ConversationID": "AG_20260503_010010031cpgqhfjz357",
    "TransactionID": null
  }
}
```

**Result codes for common errors:**
- `0` = Success
- `1` = Insufficient balance in Utility account
- `2` = Amount below minimum (KSh 10)
- `3` = Amount below limit rule
- `4` = Amount exceeds limit rule
- `21` = API user not allowed to initiate B2C
- `2001` = Initiator credentials invalid
- `2006` = B2C account not active
- `2040` = Customer not registered on M-Pesa

**Current handler** in `LoanService::processMpesaCallback()`:
```php
if ($payload['status'] === 'completed') {
    // Activate loan, generate repayment schedule
    $this->updateLoanStatus($loan, 'active');
} else {
    // Loan remains in 'approved' status
    // Transaction status = 'failed'
}
```

Admin must diagnose the error (check logs, audit trail) and retry or contact support.

---

## Enhanced Disbursement Flow with Admin PIN

### Step 1: Admin PIN Setup (One-time)

Add admin PIN entry to Settings:

```sql
-- Migration: Add admin_disbursement_pin to settings
INSERT INTO settings (key, value) VALUES ('admin_disbursement_pin', '1234');
```

### Step 2: PIN Verification UI

Update disburse button in admin panel to require PIN:

```blade
<!-- resources/views/admin/loans/show.blade.php -->
<form method="POST" action="{{ route('admin.loans.disburse', $loan) }}" id="disburseForm">
    @csrf
    <button type="button" class="btn btn-primary" onclick="showPinModal()">
        Disburse Loan
    </button>
</form>

<div id="pinModal" class="modal">
    <div class="modal-content">
        <h3>Enter Admin PIN</h3>
        <input type="password" id="adminPin" placeholder="4-digit PIN" maxlength="4">
        <button onclick="submitDisbursement()">Confirm</button>
    </div>
</div>

<script>
function showPinModal() {
    document.getElementById('pinModal').style.display = 'block';
}

function submitDisbursement() {
    const pin = document.getElementById('adminPin').value;
    
    if (pin.length !== 4 || !/^\d+$/.test(pin)) {
        alert('PIN must be 4 digits');
        return;
    }
    
    // Add PIN to form as hidden field
    const form = document.getElementById('disburseForm');
    form.innerHTML += `<input type="hidden" name="admin_pin" value="${pin}">`;
    form.submit();
}
</script>
```

### Step 3: PIN Validation in Controller

Update `LoanController::disburse()`:

```php
public function disburse(Loan $loan, Request $request)
{
    if ($loan->status !== 'approved') {
        return back()->with('error', 'Only approved loans can be disbursed.');
    }

    // Validate admin PIN
    $providedPin = $request->input('admin_pin');
    $storedPin = Setting::where('key', 'admin_disbursement_pin')->value('value');

    if (!$providedPin) {
        return back()->with('error', 'Admin PIN is required.');
    }

    if ($providedPin !== $storedPin) {
        $this->auditService->log(
            $request->user(),
            'loan.disburse.pin_failed',
            'Loan',
            $loan->id,
            ['reason' => 'Invalid admin PIN attempt'],
            'Failed disbursement: PIN validation failed',
            $request
        );
        return back()->with('error', 'Invalid admin PIN.');
    }

    // Verify user has B2C initiator role
    if ($request->user()->role !== 'admin') {
        return back()->with('error', 'Only admins can disburse loans.');
    }

    try {
        $this->loanService->disburseLoan($loan);

        $this->auditService->log(
            $request->user(),
            'loan.disbursed',
            'Loan',
            $loan->id,
            [
                'status' => 'approved → pending_disbursement',
                'b2c_initiated' => true,
            ],
            'Loan disbursement initiated via B2C',
            $request
        );

        return back()->with('success', 'Loan disbursement initiated. Awaiting M-Pesa callback.');

    } catch (\Exception $e) {
        $this->auditService->log(
            $request->user(),
            'loan.disburse.failed',
            'Loan',
            $loan->id,
            ['error' => $e->getMessage()],
            'Disbursement failed',
            $request
        );
        return back()->with('error', 'Failed to initiate disbursement: ' . $e->getMessage());
    }
}
```

---

## Error Scenarios & Handling

### Scenario 1: Insufficient Funds

```
Admin initiates disbursement with PIN ✓
B2C API call succeeds → ConversationID issued ✓
Safaricom processes → ResultCode=1 (Insufficient balance)
Callback to ResultURL with error
```

**Handler response:**
```php
public function b2cResult(Request $request)
{
    $result = $data['Result'];
    $resultCode = $result['ResultCode'];
    
    if ($resultCode === 1) {
        $payload['status'] = 'failed';
        // ... store error details
    }
}
```

**Loan status:** Remains 'approved'
**Transaction status:** 'failed'
**Admin action:** Move funds to utility account, retry

---

### Scenario 2: Invalid API Credentials

```
PIN validated ✓
B2C API call fails with 401 or credential error
MpesaApiService throws RuntimeException
```

**Handler:**
```php
try {
    $response = $mpesaService->sendB2cPayment(...);
} catch (\RuntimeException $e) {
    // Log error
    // Transaction status remains 'pending'
    // Loan remains 'approved'
    throw new \Exception("B2C API error: " . $e->getMessage());
}
```

**Admin view:** Error message, retry possible

---

### Scenario 3: Duplicate OriginatorConversationID

```
Admin retries disbursement immediately (before first callback)
New B2C request with same OriginatorConversationID
Safaricom rejects: "Duplicate OriginatorConversationID"
```

**Prevention:** Generate new UUID on each disbursement attempt
**Current implementation:** ✓ Already does this in `LoanService::disburseLoan()`

---

### Scenario 4: Timeout (No Callback After 30 seconds)

```
B2C API ACK received ✓
Processing ongoing
30s timeout → Safaricom calls QueueTimeOutURL
```

**Handler:**
```php
public function b2cTimeout(Request $request)
{
    $transaction->update([
        'status' => 'failed',
        'callback_data' => array_merge($transaction->callback_data ?? [], ['timeout' => $result]),
    ]);
}
```

**Loan status:** 'approved'
**Transaction status:** 'failed'
**Note:** May eventually receive ResultURL callback with actual result

---

## Complete Error Response Examples

### Error: Customer Not Registered

```json
{
  "Result": {
    "ResultCode": 2040,
    "ResultDesc": "Credit Party customer type (Unregistered or Registered Customer) can't be supported by the service.",
    "OriginatorConversationID": "37fe42b6-786e-4465-8d92-bcf99cc094e5",
    "ConversationID": "AG_20260503_010010031cpgqhfjz357"
  }
}
```

### Error: Initiator Credentials Invalid

```json
{
  "Result": {
    "ResultCode": 2001,
    "ResultDesc": "The initiator information is invalid.",
    "OriginatorConversationID": "37fe42b6-786e-4465-8d92-bcf99cc094e5",
    "ConversationID": "AG_20260503_010010031cpgqhfjz357"
  }
}
```

### Error: B2C Account Not Active

```json
{
  "Result": {
    "ResultCode": 2006,
    "ResultDesc": "Declined due to account rule: The account status does not allow this transaction.",
    "OriginatorConversationID": "37fe42b6-786e-4465-8d92-bcf99cc094e5",
    "ConversationID": "AG_20260503_010010031cpgqhfjz357"
  }
}
```

---

## Database Schema Notes

### Transaction Record

```
Table: transactions

Columns:
- id (PK)
- user_id (FK to users)
- loan_id (FK to loans)
- repayment_id (nullable)
- checkout_request_id (STK Push tracking)
- reference (text)
- mpesa_code (text) ← Stores OriginatorConversationID or ConversationID
- mpesa_transaction_id (text) ← Stores M-Pesa TransactionID (e.g., SG632NMUAB)
- amount (decimal:2)
- type (enum: payment, disbursement)
- status (enum: pending, completed, failed, reversed)
- callback_data (json) ← Full callback payload
- processed_at (timestamp)
- created_at, updated_at
```

**Usage in B2C flow:**
```php
// Before request
$transaction->mpesa_code = $originatorConversationId;
$transaction->status = 'pending';

// After callback
$transaction->mpesa_transaction_id = $result['TransactionID'];  // "SG632NMUAB"
$transaction->mpesa_code = $result['ConversationID'];  // "AG_20260503_..."
$transaction->callback_data = $result;
$transaction->status = 'completed' or 'failed';
$transaction->processed_at = now();
```

---

## Summary Table: Transaction Code Mapping

| Code | Source | When Available | Use |
|------|--------|-----------------|-----|
| `OriginatorConversationID` | Generated by app (UUID) | Before B2C request | Track request, prevent duplicates |
| `ConversationID` | From Daraja ACK/Callback | On API response | Safaricom tracking ID |
| `TransactionID` | From M-Pesa | On success callback only | Customer receipt number |
| `CheckoutRequestID` | For STK Push | On STK initiation | STK tracking (repayments) |
| `mpesa_transaction_id` | `TransactionID` | Success callback | What customer sees in SMS |

---

## Next Steps

1. **Create Settings UI** to allow admin to set/change PIN
2. **Add rate limiting** to PIN entry (max 3 attempts)
3. **Log all disbursement attempts** with result codes
4. **Add admin dashboard** to monitor B2C transaction status
5. **Implement transaction retry** with exponential backoff for timeouts

---

## Actual App Route/Callback Mapping

The current implementation uses these exact routes for M-Pesa B2C callbacks:

- `POST /api/payment/b2c/result`
  - Controller: `App\Http\Controllers\Api\MpesaCallbackController::b2cResult`
  - Purpose: final B2C result callback from Safaricom
- `POST /api/payment/b2c/timeout`
  - Controller: `App\Http\Controllers\Api\MpesaCallbackController::b2cTimeout`
  - Purpose: B2C timeout callback when processing takes too long

## Key Flow Points to Confirm

- `PartyB` should be the borrower phone: `loan.user.mpesa_phone`
- `OriginatorConversationID` must be generated by the app and stored on the pending transaction
- `MPESA_ENV` should be set to `sandbox` for testing or `production` for live Daraja endpoints
- `ResultURL` should be set to `MPESA_B2C_RESULT_URL`
- `QueueTimeOutURL` should be set to `MPESA_B2C_TIMEOUT_URL`
- The final loan activation only happens after the result callback with `ResultCode === 0`

## Recommended Doc Fix

If this doc is used for implementation guidance, the important correction is:

- The first API call is not the final disbursement confirmation.
- The app should treat the ACK from `/mpesa/b2c/v3/paymentrequest` as an acceptance of the request for processing.
- The final loan status change to `active` must wait for the callback on `/api/payment/b2c/result` with `ResultCode === 0`.
- If the callback is a timeout or error, the loan remains `approved` and the transaction is marked `failed`.

## Validation Checklist

1. Confirm `MPESA_B2C_RESULT_URL` is publicly reachable and points to `/api/payment/b2c/result`.
2. Confirm `MPESA_B2C_TIMEOUT_URL` is publicly reachable and points to `/api/payment/b2c/timeout`.
3. Verify `PartyB` is the borrower’s registered M-Pesa phone number.
4. Verify the application stores `OriginatorConversationID` in the transaction before sending.
5. Verify the result callback uses `OriginatorConversationID` to identify the pending transaction.

---

## Suggested Improvements

- Add an audit trail entry when a PIN check fails.
- Show admin-friendly callback status in the loan details screen.
- Surface pending disbursement state in the admin loan view until callback completion.
- Keep duplicate disbursement prevention in `LoanService::disburseLoan()` by checking for existing pending/completed disbursements.
- Ensure the UI refreshes loan state after callback processing so the admin/officer/user sees the updated status without a page reload.
