# Feature: Payments & Mobile Money

> **Status:** Specified, not built · **Phase:** 4 · **Depends on:** posting engine (2.1), savings (2.6), loans (3.7)
> **Regulatory basis:** National Payment Systems Act 2015; Electronic Money Regulations; GN 679

---

## Purpose

In Tanzania, mobile money *is* the payment rail. Members repay loans from
M-Pesa, receive disbursements to Airtel Money, and deposit savings from
HaloPesa. Cash at a counter is the exception in most of the country, not the
norm. This feature is therefore not an integration nicety — it is how money
actually moves.

---

## Providers

| Provider | Operator | Needed for |
|---|---|---|
| **M-Pesa** | Vodacom | C2B collection, B2C disbursement — the largest by far |
| **Airtel Money** | Airtel | Collection, disbursement |
| **Mixx by Yas** | Yas (formerly Tigo) | Collection, disbursement |
| **HaloPesa** | Halotel | Collection, disbursement |
| **TIPS** | BoT instant payment system | Interbank transfers |
| **Banks** | NMB, CRDB, NBC, TPB | Bulk disbursement, settlement |

---

## The abstraction comes first

Every provider has a different API shape, a different authentication scheme, a
different callback format, and a different idea of what an error is. Writing
against any one of them directly means rewriting for the next.

So: a `PaymentGateway` contract, one driver per provider, and a **fake driver**
built first.

```php
interface PaymentGateway
{
    public function collect(CollectionRequest $request): PaymentResult;
    public function disburse(DisbursementRequest $request): PaymentResult;
    public function status(string $reference): PaymentStatus;
    public function parseCallback(Request $request): ?CallbackPayload;
}
```

The fake driver is not a testing convenience — it is a schedule decision.
Sandbox credentials from Tanzanian providers take weeks to obtain and often
require a licence the institution is still applying for. Building against the
fake means the disbursement workflow, reconciliation, retry logic and ledger
postings are all finished and tested before the first real credential arrives.
When it does, the work is writing one driver, not building the feature.

---

## Flows

### Collection (money in — repayments, deposits)

```
Member initiates on their phone  ──▶  Provider  ──▶  our webhook
                                                          │
                                          verify signature │
                                          idempotency check│
                                                          ▼
                                            match to member / loan
                                                          │
                                                          ▼
                                              post to ledger, notify
```

Also supported: **STK push**, where the system asks the provider to prompt the
member's phone. Better completion rates, because the member does not have to
remember a paybill number and reference.

### Disbursement (money out — loans, dividends, withdrawals)

Requires an approved instruction, a check of available float, then the provider
call. Disbursement is **not** marked complete on API acceptance — only on the
provider's confirmation callback. Treating acceptance as success is how a loan
gets marked disbursed while the member never receives the money.

---

## The things that go wrong

### Idempotency

Providers retry callbacks. They retry aggressively, and they retry after you
have already responded 200. A callback handler that is not idempotent will post
the same repayment two or three times.

Every callback carries a provider transaction ID. Store it with a unique
constraint and treat a duplicate as an accepted no-op:

```php
$existing = PaymentCallback::where('provider_reference', $ref)->first();

if ($existing) {
    return response()->json(['status' => 'already_processed'], 200);
}
```

Return 200, not an error — an error response makes the provider retry harder.

### Unmatched payments

Members type the wrong reference constantly, or none at all. A payment that
cannot be matched to a member or loan goes to a **suspense account** and a
manual matching queue. It is never guessed at, and never dropped.

Matching attempts, in order: exact reference → phone number against member
records → amount and timing against expected instalments → manual.

### Timeouts and unknown states

A request that times out has an **unknown** outcome, not a failed one. The money
may have moved. The only safe action is to query status by reference and act on
the answer; retrying blindly risks paying twice.

Reconciliation therefore treats three states distinctly: confirmed, failed, and
**unknown-pending-query**. Collapsing the third into either of the others is how
double disbursements happen.

### Partial payments

A member owing TZS 385,000 sends TZS 100,000. It applies through the normal
allocation order (fees → penalties → interest → principal); the loan stays in
arrears; the classification clock does not reset.

---

## The trust account

Under the electronic money regulations, customer funds held as mobile-money
float are **not the institution's money**. They sit in a segregated trust
account — ledger account 2050 — and never merge with institutional cash.

This is a compliance requirement, not a presentation preference. Daily
reconciliation must show the trust account balance equal to the sum of customer
obligations. A shortfall is a reportable event.

---

## Fees

Providers charge per transaction. Whether the institution absorbs that or passes
it to the member is configurable per product and per channel, but it must be
**disclosed before the member confirms** — Reg 53 requires transparent pricing,
and a fee discovered after the fact is a complaint.

---

## Reconciliation

Daily, per provider. The provider's settlement report against our transaction
records:

- **In both, amounts agree** → reconciled
- **In ours only** → payment initiated but not settled; query by status
- **In theirs only** → callback missed; replay it
- **Amounts differ** → escalate, never auto-adjust

Most discrepancies are timing and resolve the next day. Some are not. The rule
is that no reconciliation difference is ever written off automatically,
regardless of size — a systematic small difference is exactly what fraud looks
like early on.

---

## Test checklist

- [ ] Duplicate callbacks are no-ops returning 200
- [ ] Callback signatures verified; unsigned or mis-signed requests rejected
- [ ] Unmatched payments land in suspense, never guessed
- [ ] Timeout produces unknown state and triggers a status query, not a retry
- [ ] Disbursement completes only on confirmation callback, not API acceptance
- [ ] Failed disbursement rolls back the loan to pre-disbursement state
- [ ] Trust account never merges with institutional cash
- [ ] Every payment posts a balanced journal entry
- [ ] Fees disclosed before confirmation
- [ ] Partial payment allocates correctly and does not reset arrears
- [ ] Reconciliation classifies all four discrepancy types
- [ ] No automatic write-off of any reconciliation difference
- [ ] Tenant isolation — a callback for tenant A never posts to tenant B

---

## Build order

1. `PaymentGateway` contract, `PaymentResult`, `CallbackPayload`
2. **Fake driver** + the full flow built and tested against it
3. Transaction model, states, idempotency constraints
4. Webhook endpoints with signature verification
5. Matching engine and suspense handling
6. Ledger postings including the trust account
7. Reconciliation, four-way classification
8. M-Pesa driver *(first real one — largest volume)*
9. Airtel, Mixx, HaloPesa drivers
10. TIPS and bank transfer
11. Retry and dunning schedules
