# ISP360 Payments Integration Specification

**Version:** 1.0  
**Date:** 2026-06-13  
**Prepared by:** Polakis Technologies  

Companion: [DATABASE_DESIGN.md](DATABASE_DESIGN.md) | [RBAC_SPEC.md](RBAC_SPEC.md) | [PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md) | [UI_SHELL_PATTERN.md](UI_SHELL_PATTERN.md)

---

## 1. Overview

ISP360 supports **multi-country mobile money and payment gateway integrations** for African ISPs. The platform handles:

- **Collections** — STK push / USSD prompt (customer pays ISP)
- **C2B** — Customer-initiated paybill/till payments (M-Pesa)
- **Manual payments** — Cash, bank, cheque recorded by finance staff
- **Allocation** — Apply payments to invoices (`sp_allocate_payment`)
- **Refunds** — Reverse completed gateway payments
- **Disbursements** — B2C payouts (commissions, refunds to wallet)
- **Reconciliation** — Match gateway statements against system records

All payment operations are **tenant-scoped**. Gateway credentials are encrypted per tenant in `payment_gateway_configs`.

### Core Classes

| Class | File | Responsibility |
|-------|------|----------------|
| `PaymentGateway` | `includes/PaymentGateway.php` | Abstract gateway interface |
| `MpesaGateway` | `includes/MpesaGateway.php` | Safaricom Daraja (KE/TZ) |
| `MomoGateway` | `includes/MomoGateway.php` | MTN MoMo (UG/SS) |
| `AirtelGateway` | `includes/AirtelGateway.php` | Airtel Money (KE/UG/TZ) |
| `TigoGateway` | `includes/TigoGateway.php` | Tigo Pesa (TZ) |
| `PaymentManager` | `includes/PaymentManager.php` | Orchestration, allocation, status lifecycle |
| `ReconciliationEngine` | `includes/ReconciliationEngine.php` | Statement import and matching |

### Design Principles

| Principle | Implementation |
|-----------|----------------|
| ISP360 owns payment truth | `payments` table is canonical; gateway tables are audit trail |
| Never store plaintext secrets | `consumer_secret_enc`, `passkey_enc` via `Encryption.php` |
| Idempotent callbacks | `payment_idempotency_keys` + external ref dedup |
| Async callback processing | Log to `payment_callbacks` first; process in same request or cron retry |
| Tenant gateway config | Per-tenant `payment_gateway_configs`; platform catalog in `payment_gateways` |
| Legacy reference | `includes/mpesa_helper.php` — patterns only; rewrite to `MpesaGateway` |

### Supported Gateways (Seed Data)

| Code | Gateway | Country | STK | Collection | Disburse | Refund |
|------|---------|---------|-----|------------|----------|--------|
| `mpesa_ke` | M-Pesa Kenya (Daraja) | Kenya | ✓ | ✓ | ✓ | ✓ |
| `airtel_ke` | Airtel Money Kenya | Kenya | ✓ | ✓ | — | — |
| `momo_ug` | MTN MoMo Uganda | Uganda | ✓ | ✓ | ✓ | ✓ |
| `airtel_ug` | Airtel Money Uganda | Uganda | ✓ | ✓ | — | — |
| `mpesa_tz` | M-Pesa Tanzania | Tanzania | ✓ | ✓ | ✓ | ✓ |
| `tigo_tz` | Tigo Pesa Tanzania | Tanzania | ✓ | ✓ | — | — |
| `airtel_tz` | Airtel Money Tanzania | Tanzania | ✓ | ✓ | — | — |
| `momo_ss` | MTN MoMo South Sudan | South Sudan | ✓ | ✓ | ✓ | — |

Manual methods (no gateway class): `cash`, `bank`, `card`, `cheque`, `other`.

---

## 2. Architecture

```mermaid
flowchart TB
    subgraph ui [Admin & Portal UI]
        Payments["payments.php"]
        PayView["payment_view.php"]
        PayAlloc["payment_allocate.php"]
        MpesaLog["mpesa_transactions.php"]
        Recon["payment_reconciliation.php"]
        Settings["settings_payments.php"]
        PortalPay["portal_pay.php"]
    end

    subgraph ajax [AJAX Layer]
        RecordPay["ajax/record_payment.php"]
        AllocPay["ajax/allocate_payment.php"]
        StkPush["ajax/portal_stk_push.php"]
        RunRecon["ajax/run_reconciliation.php"]
        SaveGw["ajax/save_payment_gateway_config.php"]
    end

    subgraph api [Public API — Callbacks]
        CbMpesa["api/v1/payments/callback/mpesa.php"]
        CbMomo["api/v1/payments/callback/momo.php"]
        CbAirtel["api/v1/payments/callback/airtel.php"]
        CbTigo["api/v1/payments/callback/tigo.php"]
        StkApi["api/v1/payments/stk_push.php"]
    end

    subgraph core [Core Classes]
        PM["PaymentManager.php"]
        PG["PaymentGateway.php"]
        Mpesa["MpesaGateway.php"]
        Momo["MomoGateway.php"]
        Airtel["AirtelGateway.php"]
        Tigo["TigoGateway.php"]
        ReconEng["ReconciliationEngine.php"]
        Enc["Encryption.php"]
    end

    subgraph data [Database]
        Pay["payments"]
        Alloc["payment_allocations"]
        Inv["invoices"]
        GwCfg["payment_gateway_configs"]
        MpesaTx["mpesa_transactions"]
        MomoTx["momo_transactions"]
        Callbacks["payment_callbacks"]
        Idem["payment_idempotency_keys"]
        ReconTbl["payment_reconciliation"]
    end

    subgraph cron [Background]
        StatusPoll["cron/payment_status_check.php"]
        ReconCron["cron/payment_reconcile.php"]
    end

    Payments --> RecordPay
    PortalPay --> StkPush
    StkPush --> PM
    RecordPay --> PM
    CbMpesa --> PM
    CbMomo --> PM
    PM --> PG
    PG --> Mpesa
    PG --> Momo
    PM --> Pay
    PM --> MpesaTx
    PM --> Callbacks
    AllocPay --> PM
    Recon --> RunRecon
    RunRecon --> ReconEng
    StatusPoll --> PM
    ReconCron --> ReconEng
```

---

## 3. Database Tables

### 3.1 Core Billing

| Table | Role |
|-------|------|
| `payments` | Canonical payment record (`payment_ref`, `status`, `amount_allocated`) |
| `payment_allocations` | Payment-to-invoice links |
| `invoices` | Updated by `sp_allocate_payment` (balance, status) |
| `payment_plans` | Installment schedules |
| `payment_plan_installments` | Per-installment due amounts |
| `payment_status_history` | Status transition audit |

### 3.2 Gateway Infrastructure

| Table | Role |
|-------|------|
| `payment_gateways` | Platform catalog (global, seeded) |
| `payment_gateway_configs` | Per-tenant credentials and callback URLs |
| `payment_idempotency_keys` | Duplicate request prevention |
| `payment_callbacks` | Raw inbound callback log |
| `gateway_webhook_log` | Signed webhook events |

### 3.3 Gateway Transaction Logs

| Table | Gateway | Key External Fields |
|-------|---------|---------------------|
| `mpesa_transactions` | M-Pesa | `checkout_request_id`, `mpesa_receipt` |
| `momo_transactions` | MTN MoMo | `external_id`, `financial_transaction_id` |
| `airtel_transactions` | Airtel | `transaction_id`, `airtel_money_id` |
| `tigo_transactions` | Tigo | `transaction_id`, `tigo_reference` |

### 3.4 Operations

| Table | Role |
|-------|------|
| `payment_refunds` | Refund requests and status |
| `payment_disbursements` | B2C / disbursement payouts |
| `payment_reconciliation` | Recon run header |
| `payment_reconciliation_items` | Per-transaction match results |

### 3.5 `payments` Status Lifecycle

```
pending ──► completed ──► refunded
   │            │
   │            └──► reversed
   └──► failed
```

| Status | Meaning |
|--------|---------|
| `pending` | STK sent, awaiting callback or manual confirmation |
| `completed` | Funds received; available for allocation |
| `failed` | Gateway rejected or timed out |
| `reversed` | Staff/gateway reversal before allocation |
| `refunded` | Refund processed after completion |

### 3.6 `payment_gateway_configs` Field Usage

| Field | M-Pesa | MoMo | Airtel | Tigo |
|-------|--------|------|--------|------|
| `merchant_id` | — | API User UUID | Client ID | Merchant code |
| `shortcode` | Paybill/Till | — | — | — |
| `consumer_key` | Daraja key | Subscription key | Client ID | API key |
| `consumer_secret_enc` | Daraja secret | API secret | Client secret | API secret |
| `passkey_enc` | STK passkey | — | — | — |
| `callback_url` | STK result URL | Collection callback | Webhook URL | Callback URL |
| `config_json` | Extra (initiator, cert) | Target environment | Country code | Product code |

---

## 4. Class: `PaymentGateway` (Abstract)

**File:** `includes/PaymentGateway.php`

```php
abstract class PaymentGateway
{
    protected PDO $pdo;
    protected int $tenant_id;
    protected array $config;
    protected int $gateway_id;
    protected int $config_id;

    public function __construct(PDO $pdo, int $tenant_id, int $config_id);

    /** Load and decrypt payment_gateway_configs row. */
    protected function loadConfig(): void;

    /** Normalize MSISDN to country format (254..., 256..., 255...). */
    public function formatPhone(string $phone, string $country_code): string;

    /**
     * Initiate customer collection (STK / request-to-pay).
     * @return array{success: bool, external_ref: ?string, raw: array}
     */
    abstract public function initiateCollection(
        string $phone,
        float $amount,
        string $reference,
        ?string $description = null
    ): array;

    /**
     * Query transaction status by external reference.
     */
    abstract public function queryStatus(string $external_ref): array;

    /**
     * Process inbound callback/webhook payload.
     * Must be idempotent.
     */
    abstract public function handleCallback(array $payload, array $headers = []): array;

    /**
     * Initiate disbursement (B2C) if gateway supports it.
     */
    public function initiateDisbursement(
        string $phone,
        float $amount,
        string $reference,
        ?string $purpose = null
    ): array;

    /**
     * Initiate refund if gateway supports it.
     */
    public function initiateRefund(string $original_ref, float $amount, string $reason): array;

    /** Validate callback authenticity (signature, IP allowlist). */
    abstract public function validateCallback(array $headers, string $raw_body): bool;
}
```

### 4.1 Gateway Factory

**File:** `includes/PaymentManager.php`

```php
public static function gatewayFor(
    PDO $pdo,
    int $tenant_id,
    string $gateway_code
): PaymentGateway {
    return match ($gateway_code) {
        'mpesa_ke', 'mpesa_tz' => new MpesaGateway($pdo, $tenant_id, $gateway_code),
        'momo_ug', 'momo_ss'   => new MomoGateway($pdo, $tenant_id, $gateway_code),
        'airtel_ke', 'airtel_ug', 'airtel_tz' => new AirtelGateway($pdo, $tenant_id, $gateway_code),
        'tigo_tz'              => new TigoGateway($pdo, $tenant_id, $gateway_code),
        default => throw new PaymentGatewayException("Unsupported gateway: {$gateway_code}"),
    };
}
```

---

## 5. Class: `MpesaGateway`

**File:** `includes/MpesaGateway.php`

Migrates patterns from legacy `includes/mpesa_helper.php` to PDO + ISP360 schema.

### 5.1 Daraja API Endpoints

| Operation | Sandbox | Production |
|-----------|---------|------------|
| OAuth token | `sandbox.safaricom.co.ke/oauth/v1/generate` | `api.safaricom.co.ke/oauth/v1/generate` |
| STK Push | `/mpesa/stkpush/v1/processrequest` | same |
| STK Query | `/mpesa/stkpushquery/v1/query` | same |
| C2B Register URL | `/mpesa/c2b/v1/registerurl` | same |
| B2C | `/mpesa/b2c/v1/paymentrequest` | same |
| Reversal | `/mpesa/reversal/v1/request` | same |

### 5.2 STK Push Flow

```php
public function initiateCollection(
    string $phone,
    float $amount,
    string $reference,
    ?string $description = null
): array {
    // 1. Get OAuth token (cache 50 min in memory/APCu per config_id)
    // 2. Build password: base64(shortcode + passkey + timestamp)
    // 3. POST STK push
    // 4. INSERT mpesa_transactions (status=initiated)
    // 5. INSERT payments (status=pending, payment_method=mpesa)
    // 6. Link mpesa_transactions.payment_id
    // 7. Return checkout_request_id to UI for polling
}
```

**STK request fields:**

| Field | Value |
|-------|-------|
| `BusinessShortCode` | `config.shortcode` |
| `TransactionType` | `CustomerPayBillOnline` or `CustomerBuyGoodsOnline` |
| `CallBackURL` | `config.callback_url` or `api/v1/payments/callback/mpesa.php?tenant={code}` |
| `AccountReference` | `payment_ref` or invoice number (max 12 chars) |

### 5.3 M-Pesa Callback Processing

```php
public function handleCallback(array $payload, array $headers = []): array
{
    // 1. INSERT payment_callbacks (processing_status=received)
    // 2. Extract CheckoutRequestID, ResultCode
    // 3. SELECT mpesa_transactions FOR UPDATE by checkout_request_id
    // 4. Idempotency: if already completed, return 200
    // 5. ResultCode 0:
    //    - Update mpesa_transactions (mpesa_receipt, status=completed)
    //    - PaymentManager::completePayment()
    //    - Optional: auto-allocate to linked invoice_id
    // 6. ResultCode != 0:
    //    - Update mpesa_transactions (status=failed/cancelled/timeout)
    //    - PaymentManager::failPayment()
    // 7. UPDATE payment_callbacks (processing_status=processed)
}
```

**Result code mapping:**

| ResultCode | `mpesa_transactions.status` |
|------------|----------------------------|
| `0` | `completed` |
| `1032` | `cancelled` (user cancelled) |
| `1037` | `timeout` |
| Other | `failed` |

### 5.4 C2B (Paybill) Validation

For unsolicited C2B payments:

1. Register validation URL via Daraja `registerurl`
2. Validation endpoint returns `ResultCode=0` to accept
3. Confirmation callback creates `payments` + `mpesa_transactions` if no matching pending STK
4. Match by `BillRefNumber` → `payment_ref` or `customer_number`

---

## 6. Class: `MomoGateway`

**File:** `includes/MomoGateway.php`

MTN MoMo Open API (Uganda, South Sudan).

### 6.1 API Flow

| Step | Endpoint |
|------|----------|
| Create API user | Provisioning (sandbox) |
| Get API key | `/collection/token/` |
| Request to pay | `POST /collection/v1_0/requesttopay` |
| Check status | `GET /collection/v1_0/requesttopay/{referenceId}` |
| Disbursement | `POST /disbursement/v1_0/transfer` |

### 6.2 Key Fields

| Field | Usage |
|-------|-------|
| `X-Reference-Id` | UUID v4 — stored as `momo_transactions.external_id` |
| `X-Target-Environment` | `sandbox` or `mtnuganda` / `mtnsouthsudan` |
| `externalId` | ISP360 `payment_ref` in request body |
| `payer.partyIdType` | `MSISDN` |
| `payer.partyId` | Formatted phone |

### 6.3 Callback

MTN sends callback to `config.callback_url`. Signature validated via `Ocp-Apim-Subscription-Key` header match + optional HMAC in `config_json`.

Status mapping: `SUCCESSFUL` → `completed`, `FAILED` → `failed`, `PENDING` → `pending`.

---

## 7. Class: `AirtelGateway`

**File:** `includes/AirtelGateway.php`

Airtel Africa Money API (Kenya, Uganda, Tanzania).

### 7.1 Collection Flow

1. OAuth2 client credentials → bearer token
2. `POST /merchant/v1/payments/` with `reference`, `subscriber.msisdn`, `transaction.amount`
3. Store `transaction_id` (UUID) in `airtel_transactions`
4. Callback or poll `GET /standard/v1/payments/{transaction_id}`

### 7.2 Country Variants

| Gateway Code | Currency | Base URL (prod) |
|--------------|----------|-----------------|
| `airtel_ke` | KES | `openapi.airtel.africa` |
| `airtel_ug` | UGX | same |
| `airtel_tz` | TZS | same |

Country-specific config in `config_json.country` / `config_json.currency`.

---

## 8. Class: `TigoGateway`

**File:** `includes/TigoGateway.php`

Tigo Pesa Tanzania (MIC Tanzania API).

### 8.1 USSD Push / Biller

1. Authenticate → session token
2. `POST /pushBillPay` with `msisdn`, `amount`, `reference`
3. Store `transaction_id` in `tigo_transactions`
4. Callback at `config.callback_url`

Limited disbursement/refund — manual reconciliation for exceptions.

---

## 9. Class: `PaymentManager`

**File:** `includes/PaymentManager.php`

High-level payment orchestration. All UI, portal, and cron jobs call this class.

### 9.1 Payment Recording

```php
/**
 * Create payment record with unique payment_ref.
 * Format: PAY-{tenant_code}-{YYYYMMDD}-{sequence}
 */
public function createPayment(array $data, int $user_id): int;

/**
 * Mark payment completed — updates status, payment_status_history, audit_logs.
 * Triggers: notification, auto-allocation if invoice_id in metadata.
 */
public function completePayment(
    int $payment_id,
    int $tenant_id,
    ?string $external_ref = null,
    ?array $metadata = null
): void;

/** Mark payment failed with reason. */
public function failPayment(int $payment_id, int $tenant_id, string $reason): void;
```

### 9.2 Manual Payment (Cash/Bank)

```php
/**
 * Record offline payment by finance staff.
 * Immediately status=completed (no gateway table).
 */
public function recordManualPayment(
    int $tenant_id,
    int $customer_id,
    string $method,  // cash|bank|cheque|card|other
    float $amount,
    int $currency_id,
    ?int $invoice_id,
    int $received_by,
    ?string $notes = null
): int;
```

### 9.3 Allocation

Delegates to stored procedure `sp_allocate_payment`:

```php
public function allocateToInvoice(
    int $tenant_id,
    int $payment_id,
    int $invoice_id,
    float $amount,
    int $allocated_by
): void {
    $stmt = $this->pdo->prepare(
        'CALL sp_allocate_payment(?, ?, ?, ?, ?)'
    );
    $stmt->execute([$tenant_id, $payment_id, $invoice_id, $amount, $allocated_by]);
}
```

**Auto-allocation rules (configurable per tenant):**

| Setting | Behavior |
|---------|----------|
| `payments.auto_allocate` = true | On `completePayment`, allocate to `metadata.invoice_id` |
| FIFO mode | Oldest open invoice for customer if no invoice specified |
| Partial | Allocate min(payment unallocated, invoice balance) |

### 9.4 Refunds

```php
public function requestRefund(
    int $tenant_id,
    int $payment_id,
    float $amount,
    string $reason,
    int $requested_by
): int;
```

Flow:

1. Validate payment `status = completed` and amount ≤ payment amount
2. INSERT `payment_refunds` (status=pending)
3. If gateway supports refund → `gateway.initiateRefund()`
4. On success → `payments.status = refunded`, reverse allocations if configured

### 9.5 Disbursements

```php
public function initiateDisbursement(
    int $tenant_id,
    string $gateway_code,
    string $recipient_phone,
    float $amount,
    string $purpose,
    int $initiated_by,
    ?int $customer_id = null
): int;
```

Creates `payment_disbursements` row; calls gateway B2C/disbursement API; polls via cron if async.

### 9.6 Idempotency

```php
public function checkIdempotency(
    int $tenant_id,
    string $key,
    string $request_hash
): ?array;

public function storeIdempotency(
    int $tenant_id,
    string $key,
    array $response,
    ?int $payment_id = null
): void;
```

- Key source: `Idempotency-Key` header (API) or `hash(tenant_id + customer_id + amount + invoice_id + minute)` (portal)
- TTL: 24 hours (`expires_at`)
- Duplicate callback: match `external_ref` / `mpesa_receipt` — skip if payment already `completed`

---

## 10. Class: `ReconciliationEngine`

**File:** `includes/ReconciliationEngine.php`

### 10.1 Reconciliation Run

```php
/**
 * Compare gateway statement vs system payments for date range.
 */
public function runReconciliation(
    int $tenant_id,
    int $gateway_id,
    DateTime $period_start,
    DateTime $period_end,
    int $user_id,
    ?string $statement_csv = null
): int;
```

**Matching algorithm (priority order):**

1. **Exact match** — `external_ref` = gateway receipt ID AND amount equal
2. **Fuzzy match** — same amount ± phone within 5 minutes
3. **Duplicate** — receipt already matched to different payment
4. **Unmatched gateway** — in statement, not in system
5. **Unmatched system** — in system, not in statement

Results stored in `payment_reconciliation_items.match_status`.

### 10.2 M-Pesa Statement Import

Daraja provides:

- `QueryTransaction` for date range
- Manual CSV export from M-Pesa portal

CSV columns mapped: `Receipt No`, `Completion Time`, `Details`, `Transaction Status`, `Paid In`, `Withdrawn`.

### 10.3 Variance Resolution

| `match_status` | UI Action |
|----------------|-----------|
| `unmatched` | Manual link to payment or create adjustment |
| `duplicate` | Mark ignored or merge |
| `partial` | Split allocation review |
| `manual` | Finance user resolved with notes |

Close recon when `variance_amount = 0` or `status = closed` with documented variance.

---

## 11. Workflows

### 11.1 Portal STK Push (Customer Pays Invoice)

```mermaid
sequenceDiagram
    participant C as Customer
    participant PP as portal_pay.php
    participant AJAX as portal_stk_push.php
    participant PM as PaymentManager
    participant GW as MpesaGateway
    participant CB as callback/mpesa.php
    participant DB as MySQL

    C->>PP: Select invoice, enter phone
    PP->>AJAX: POST invoice_id, phone
    AJAX->>PM: initiatePayment()
    PM->>GW: initiateCollection()
    GW->>DB: INSERT payments pending, mpesa_transactions
    GW-->>C: Prompt on phone
    C->>GW: Enter PIN
    GW->>CB: STK callback
    CB->>PM: handleCallback()
    PM->>DB: completePayment + sp_allocate_payment
    PM-->>C: SMS/email receipt
```

### 11.2 Manual Cash Payment

```mermaid
sequenceDiagram
    participant F as Finance User
    participant UI as payments.php
    participant AJAX as record_payment.php
    participant PM as PaymentManager
    participant DB as MySQL

    F->>UI: Record cash payment
    UI->>AJAX: POST customer, amount, method
    AJAX->>PM: recordManualPayment()
    PM->>DB: INSERT payments completed
    F->>UI: Allocate to invoice
    UI->>AJAX: allocate_payment.php
    AJAX->>PM: allocateToInvoice()
    PM->>DB: CALL sp_allocate_payment
```

### 11.3 Payment Reconciliation

```mermaid
sequenceDiagram
    participant F as Finance User
    participant UI as payment_reconciliation.php
    participant RE as ReconciliationEngine
    participant DB as MySQL

    F->>UI: Select gateway, date range, upload CSV
    UI->>RE: runReconciliation()
    RE->>DB: Load payments + gateway transactions
    RE->>RE: Match algorithm
    RE->>DB: INSERT payment_reconciliation + items
    F->>UI: Review unmatched items
    F->>UI: Manual match / close
```

### 11.4 Refund

```mermaid
sequenceDiagram
    participant F as Finance User
    participant AJAX as process_refund.php
    participant PM as PaymentManager
    participant GW as MpesaGateway
    participant DB as MySQL

    F->>AJAX: Request refund
    AJAX->>PM: requestRefund()
    PM->>GW: initiateRefund() or B2C reversal
    GW-->>PM: Refund result
    PM->>DB: payment_refunds completed, payments refunded
```

---

## 12. API Endpoints

Public callback URLs must be **HTTPS** and resolve tenant from query param or path.

| Endpoint | Auth | Purpose |
|----------|------|---------|
| `api/v1/payments/callback/mpesa.php` | IP allowlist + payload validation | STK/C2B callbacks |
| `api/v1/payments/callback/momo.php` | Subscription key header | MoMo callbacks |
| `api/v1/payments/callback/airtel.php` | Bearer + signature | Airtel webhooks |
| `api/v1/payments/callback/tigo.php` | API key | Tigo callbacks |
| `api/v1/payments/stk_push.php` | API token / portal session | Initiate collection |
| `api/v1/payments/status.php` | API token | Poll payment status |
| `api/v1/payments/index.php` | API token | List payments |
| `api/v1/payments/store.php` | API token | Record payment |

### 12.1 Callback Handler Template

```php
// api/v1/payments/callback/mpesa.php
require_once __DIR__ . '/../../../includes/config.php';
require_once __DIR__ . '/../../../includes/db.php';
require_once __DIR__ . '/../../../includes/PaymentManager.php';

$raw = file_get_contents('php://input');
$payload = json_decode($raw, true) ?: [];
$tenant_code = $_GET['tenant'] ?? '';

// Resolve tenant_id from tenant_code
// Log payment_callbacks immediately
// PaymentManager::processCallback('mpesa_ke', $tenant_id, $payload, getallheaders())
// Always return 200 to gateway (retry otherwise)
header('Content-Type: application/json');
echo json_encode(['ResultCode' => 0, 'ResultDesc' => 'Accepted']);
```

### 12.2 Tenant Resolution on Callbacks

| Method | Example |
|--------|---------|
| Query param | `?tenant=acme_isp` |
| Path segment | `/callback/mpesa/acme_isp` |
| Account reference | `payment_ref` encodes tenant (platform C2B) |

Each tenant's `payment_gateway_configs.callback_url` must point to tenant-specific URL.

---

## 13. Cron Jobs

| Script | Schedule | Purpose |
|--------|----------|---------|
| `cron/payment_status_check.php` | `*/10 * * * *` | Poll `pending` STK/MoMo older than 2 min |
| `cron/payment_reconcile.php` | `*/15 * * * *` | Auto-recon prior day for tenants with `auto_reconcile=1` |
| `cron/payment_idempotency_cleanup.php` | `0 3 * * *` | Delete expired idempotency keys |

### 13.1 Status Poll Pseudocode

```php
// Find stale pending transactions
SELECT * FROM mpesa_transactions
WHERE status IN ('initiated','pending')
  AND created_at < DATE_SUB(NOW(), INTERVAL 2 MINUTE)
  AND created_at > DATE_SUB(NOW(), INTERVAL 30 MINUTE);

// For each: gateway.queryStatus(checkout_request_id)
// Update or mark timeout after 30 min
```

---

## 14. Admin UI Pages

All pages use [UI_SHELL_PATTERN.md](UI_SHELL_PATTERN.md) (`layouts/app-start.php`, PDO, `permission_check`).

| Page | Permission | Primary Tables |
|------|------------|----------------|
| `payments.php` | `payments.view` | `payments` |
| `payment_view.php` | `payments.view` | `payments`, `payment_allocations` |
| `payment_allocate.php` | `payments.create` | `payments`, `invoices` |
| `payment_plans.php` | `payments.view` | `payment_plans` |
| `mpesa_transactions.php` | `payments.view` | `mpesa_transactions` |
| `momo_transactions.php` | `payments.view` | `momo_transactions` |
| `airtel_transactions.php` | `payments.view` | `airtel_transactions` |
| `payment_callbacks.php` | `payments.view` | `payment_callbacks` |
| `payment_reconciliation.php` | `payments.reconcile` | `payment_reconciliation` |
| `payment_refunds.php` | `payments.refund` | `payment_refunds` |
| `payment_disbursements.php` | `payments.create` | `payment_disbursements` |
| `settings_payments.php` | `settings.edit` | `payment_gateway_configs` |

### 14.1 AJAX Endpoints

| Endpoint | Permission | Action |
|----------|------------|--------|
| `ajax/get_payments.php` | `payments.view` | DataTables list |
| `ajax/record_payment.php` | `payments.create` | Manual payment |
| `ajax/get_payment_details.php` | `payments.view` | Payment + allocations |
| `ajax/allocate_payment.php` | `payments.create` | Call `sp_allocate_payment` |
| `ajax/reverse_payment.php` | `payments.reverse` | Reverse unallocated payment |
| `ajax/process_refund.php` | `payments.refund` | Initiate refund |
| `ajax/initiate_disbursement.php` | `payments.create` | B2C disbursement |
| `ajax/get_mpesa_transactions.php` | `payments.view` | M-Pesa log |
| `ajax/get_momo_transactions.php` | `payments.view` | MoMo log |
| `ajax/get_airtel_transactions.php` | `payments.view` | Airtel log |
| `ajax/get_payment_callbacks.php` | `payments.view` | Callback log |
| `ajax/retry_callback.php` | `payments.edit` | Reprocess failed callback |
| `ajax/check_mpesa_status.php` | `payments.view` | Manual STK poll |
| `ajax/check_momo_status.php` | `payments.view` | Manual MoMo poll |
| `ajax/run_reconciliation.php` | `payments.reconcile` | Start recon run |
| `ajax/match_payment.php` | `payments.reconcile` | Manual match item |
| `ajax/save_payment_gateway_config.php` | `settings.edit` | Save gateway credentials |
| `ajax/test_gateway_connection.php` | `settings.edit` | OAuth / ping test |
| `ajax/portal_initiate_payment.php` | portal session | Customer STK |
| `ajax/portal_stk_push.php` | portal session | Customer STK (alias) |
| `ajax/export_payments.php` | `payments.export` | CSV export |

---

## 15. RBAC Permissions

| Code | Action |
|------|--------|
| `payments.view` | List payments, gateway logs, callbacks |
| `payments.create` | Record manual payment, allocate, disburse |
| `payments.edit` | Edit pending payment notes, retry callback |
| `payments.refund` | Process refunds |
| `payments.reverse` | Reverse completed unallocated payment |
| `payments.reconcile` | Run and close reconciliation |
| `payments.allocate` | Allocate (subset of create) |
| `payments.export` | Export payment data |
| `settings.payments` | Configure gateway credentials |

**Roles:** ISP Admin and Finance User have full payment permissions. Support Agent: `payments.view` only. Customer portal: no staff permissions — portal session only.

---

## 16. Security

| Risk | Mitigation |
|------|------------|
| Credential theft | AES-256-GCM encryption; never log secrets |
| Callback spoofing | IP allowlist (Safaricom ranges), signature validation, tenant in URL |
| Replay attacks | Idempotency keys; external ref unique constraints |
| Amount tampering | Server-side amount from invoice; ignore callback amount mismatch > 0 |
| PCI for cards | Card payments external redirect only (no PAN storage) — future phase |
| Tenant bleed | Every query includes `tenant_id = tenant_context()` |

### 16.1 M-Pesa IP Allowlist (Production)

Safaricom callback sources — validate `REMOTE_ADDR` against published CIDRs (updated annually in `config_json.allowed_ips`).

### 16.2 Phone Validation

```php
// Kenya: 2547XXXXXXXX or 07XXXXXXXX
// Uganda: 2567XXXXXXXX
// Tanzania: 2557XXXXXXXX
function validate_msisdn(string $phone, string $country_iso): bool;
```

---

## 17. Notifications

On `completePayment`:

| Channel | Template | Trigger |
|---------|----------|---------|
| SMS | `payment_received` | Customer phone |
| Email | `payment_receipt` | Customer email |
| In-app | Notification rule | Finance if amount > threshold |

Metadata in notification: `payment_ref`, `amount`, `invoice_number`, `mpesa_receipt`.

---

## 18. Multi-Currency

- Each tenant has `currency_id` on `tenants` table
- `payments.currency_id` must match tenant default or invoice currency
- Gateway amounts in local currency (KES, UGX, TZS, SSP)
- No cross-currency allocation — invoice and payment currencies must match

---

## 19. Error Handling

| Error | HTTP | User Message |
|-------|------|--------------|
| Gateway credentials missing | 422 | Payment gateway not configured |
| Invalid phone format | 400 | Invalid mobile number |
| STK timeout | 408 | Payment prompt timed out — try again |
| Duplicate receipt | 409 | This transaction was already recorded |
| Allocation exceeds balance | 422 | Amount exceeds invoice balance |
| Insufficient unallocated | 422 | Payment has no remaining unallocated amount |
| Gateway unavailable | 502 | Mobile money service unavailable |

All gateway errors logged to `payment_callbacks.error_message` and `gateway_webhook_log`.

---

## 20. Migration from Legacy `mpesa_helper.php`

| Legacy Function | ISP360 Replacement |
|-----------------|-------------------|
| `getMpesaCredentials($conn)` | `PaymentManager::getGatewayConfig($tenant_id, 'mpesa_ke')` |
| `initiateMpesaSTKPush()` | `MpesaGateway::initiateCollection()` |
| `queryMpesaSTKStatus()` | `MpesaGateway::queryStatus()` |
| `processMpesaCallback()` | `MpesaGateway::handleCallback()` + `PaymentManager::completePayment()` |
| `formatPhoneNumber()` | `PaymentGateway::formatPhone()` |
| MySQLi `$conn` | PDO `$pdo` |
| `system_settings` keys | `payment_gateway_configs` |
| Hospital `ProcessPayment` proc | `PaymentManager` + `sp_allocate_payment` |

Keep `mpesa_helper.php` as reference only — do not call from new code.

---

## 21. Implementation Checklist (Wave 2)

- [ ] `includes/PaymentGateway.php` (abstract)
- [ ] `includes/MpesaGateway.php`
- [ ] `includes/MomoGateway.php`
- [ ] `includes/AirtelGateway.php`
- [ ] `includes/TigoGateway.php`
- [ ] `includes/PaymentManager.php`
- [ ] `includes/ReconciliationEngine.php`
- [ ] API callbacks (4 files)
- [ ] Cron: `payment_status_check.php`, `payment_reconcile.php`
- [ ] AJAX endpoints (18 payment-related)
- [ ] Root pages: 12 payment admin pages
- [ ] `settings_payments.php` gateway config UI
- [ ] Portal: `portal_pay.php`, `portal_stk_push` flow
- [ ] Sandbox integration tests per gateway
- [ ] Production callback URL registration guide per tenant

---

## 22. Related Documents

| Document | Relevance |
|----------|-----------|
| [UI_SPECIFICATIONS.md](UI_SPECIFICATIONS.md) | Field-level screen specs |
| [RBAC_SPEC.md](RBAC_SPEC.md) | Permission codes |
| [PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md) | File manifest |
| [WIREGUARD_SPEC.md](WIREGUARD_SPEC.md) | Parallel integration spec pattern |
| [UI_SHELL_PATTERN.md](UI_SHELL_PATTERN.md) | Page bootstrap template |
