# Loan Settlement — Borrower App E2E (Frontend Guide)

How the **borrower** pays off a loan early from the mobile app, end to end.

- Base URL (production): `https://api.nextpaydayapp.com/api/v1`
- Base URL (staging): `https://stagingapi.nextpaydayapp.com/api/v1`
- Headers on every call: `Accept: application/json`, `Content-Type: application/json`, and `Authorization: Bearer {token}` (send the token even on the "public" endpoints — it is ignored there, and it keeps your API client simple).

Related: [loan-repayment-flows.md](loan-repayment-flows.md) (single installment payments).

---

## 1. How this relates to the admin "Settle Loan"

The admin loan page (`admin/src/app/loans/[id]`) already does settlement in two ways. The borrower app reuses the **first** one — the borrower calls the endpoints themselves.

| | Admin "Settle Loan" button (quote + link) | Admin manual settle (password) | **Borrower app (this doc)** |
|---|---|---|---|
| Quote | `GET /public/loans/{reference}/settlement` | — | **Same endpoint** |
| Pay | Copies `/settle/{reference}` link → borrower pays via `POST /public/loans/settle` | `POST /admin/loans/{id}/settle` with settlement password | `POST /loans/settle-with-wallet` **or** `POST /public/loans/settle` |
| Money collected? | Yes (Paystack) | **No** — marks paid without collecting | Yes (wallet or Paystack) |
| Who can call | Anyone with the reference | Admin only | Logged-in borrower |

**Never** call `/admin/loans/{id}/settle` from the app — it is admin-only and writes the loan off as paid without taking money.

---

## 2. The amount (read this first)

Early settlement is **not** the sum of the remaining installments. The borrower pays the remaining principal plus a small fee, and skips the future interest.

```
remaining_principal = (loan.amount / loan.duration_months) × unpaid installments
settlement_fee      = remaining_principal × settlement_fee_percentage / 100   (admin setting, default 1%)
total_payable       = remaining_principal + settlement_fee
interest_saved      = current_full_balance − remaining_principal
```

The server always recalculates `total_payable`. The app never sends an amount.

---

## 3. Flow overview

```mermaid
flowchart TD
    A[Loan detail screen<br/>GET /loans/{reference}] --> B{Show Settle button?}
    B -- no --> Z[Hide button]
    B -- yes --> C[Tap Settle loan<br/>GET /public/loans/{reference}/settlement]
    C --> D[Settlement sheet: total_payable, fee, interest_saved]
    D --> E[GET /wallet]
    E --> F{balance >= total_payable?}
    F -- yes --> G[Button: Pay from wallet<br/>POST /loans/settle-with-wallet]
    F -- no --> H[Button: Top up wallet<br/>POST /wallet/top-up]
    D --> I[Button: Pay with card / transfer<br/>POST /public/loans/settle]
    H --> H2[Open Paystack → GET /wallet/top-up/verify] --> E
    I --> J[Open authorization_url in WebView]
    J --> K[WebView reaches /payment/success → close]
    K --> L[Poll GET /loans/settlement/verify?reference= until status is final]
    G --> M[Success screen]
    L --> M
```

---

## 4. Step by step

### Step 1 — Loan detail: decide whether to show "Settle loan"

```
GET /loans/{reference}
```

Response (trimmed):

```json
{
  "loan": {
    "id": 42,
    "reference": "LN-LIFVZBSIWR",
    "status": "disbursed",
    "amount": "33333.00",
    "duration_months": 12,
    "repayment_schedules": [
      { "id": 101, "installment_number": 1, "amount": "5347.00", "due_date": "2026-10-01T00:00:00.000000Z", "status": "paid" },
      { "id": 102, "installment_number": 2, "amount": "5347.00", "due_date": "2026-11-01T00:00:00.000000Z", "status": "pending" }
    ]
  }
}
```

Keep `loan.id` (wallet endpoint) and `loan.reference` (quote + Paystack endpoints).

**Show the "Settle loan" button only when both are true:**

- `loan.status` is one of `disbursed`, `defaulting`, `managed` (also `approved` if it already has a repayment schedule)
- at least one entry in `repayment_schedules` has `status != "paid"`

Hide it for `draft`, `collection_pending`, `ready_to_apply`, `submitted`, `pending` and `paid`. The pay endpoints reject those with `422`, but the quote endpoint still answers for them, so hide the button in the app.

### Step 2 — Get the quote (tap "Settle loan")

```
GET /public/loans/{reference}/settlement?user_id={logged_in_user_id}
```

Always pass `user_id` — the server returns `403` if the loan is not theirs.

`200`:

```json
{
  "loan_id": 42,
  "loan_reference": "LN-LIFVZBSIWR",
  "total_borrowed": 33333,
  "remaining_installments": 11,
  "current_full_balance": 58817,
  "remaining_principal": 30555.25,
  "interest_saved": 28261.75,
  "settlement_fee_percentage": 1,
  "settlement_fee": 305.55,
  "total_payable": 30860.8,
  "paystack_public_key": "pk_live_..."
}
```

| Status | Meaning | App action |
|---|---|---|
| `200` with `remaining_installments == 0` | Nothing left to settle | Hide the sheet and refresh the loan |
| `403` | `user_id` doesn't own the loan | Show a generic error |
| `404` | Loan not found or already `paid` | Refresh the loan and show the paid state |

**Settlement sheet UI:**

- Big number: `total_payable`
- Rows: `remaining_principal`, "Settlement fee (`settlement_fee_percentage`%)": `settlement_fee`
- Green highlight: "You save ₦`interest_saved`"
- Optional comparison: "Instead of ₦`current_full_balance`"
- Two buttons: **Pay from wallet** and **Pay with card / transfer** (Step 3)

Re-fetch the quote each time the sheet opens. An auto-debit can pay an installment in the background and change the amount.

### Step 3 — Pick a payment method

```
GET /wallet
```

```json
{ "balance": "12000.00", "transactions": { "...": "..." } }
```

`balance` is a string, so parse it to a number.

| Condition | Wallet button | Card button |
|---|---|---|
| `balance >= total_payable` | **"Pay ₦X from wallet"** (enabled) | "Pay with card / transfer" |
| `balance < total_payable` | **"Top up ₦(total_payable − balance)"** | "Pay with card / transfer" |

---

### Step 4A — Pay from wallet

Show a confirm dialog first ("₦30,860.80 will be deducted from your wallet"), then:

```
POST /loans/settle-with-wallet
{ "loan_id": 42 }
```

`200`:

```json
{
  "message": "Loan settled successfully from wallet.",
  "loan_reference": "LN-LIFVZBSIWR",
  "amount_paid": 30860.8
}
```

This is instant. Go straight to the success screen, then refresh `GET /loans/{reference}` (now `status: "paid"`) and `GET /wallet`.

| Status | Body | App action |
|---|---|---|
| `422` | `{ "message": "Insufficient wallet balance to settle this loan.", "wallet_balance": 12000, "amount_required": 30860.8 }` | Switch to the Top up button using `amount_required − wallet_balance` |
| `404` | `{ "message": "Loan not found or already settled." }` | Refresh the loan. If it's `paid`, show success |
| `422` | `{ "message": "This loan cannot be settled." }` or `"...no unpaid installments to settle."` | Refresh the loan and hide the button |
| `422` | `errors.loan_id` | Bug: wrong or missing `loan_id` |

> Still disable the button while the request is in flight. The server now locks the loan, so a double tap can't charge twice (the second call gets `404`), but a disabled button avoids a confusing error.

#### Wallet top-up (when the balance is short)

```
POST /wallet/top-up
{ "amount": 18860.8 }
```

→ `{ "authorization_url": "...", "reference": "TOPUP-...", "access_code": "...", "paystack_public_key": "..." }`

Open `authorization_url` in a WebView and close it when the URL contains `/payment/success`. Then call:

```
GET /wallet/top-up/verify?reference=TOPUP-...
```

| `status` in body | HTTP | App action |
|---|---|---|
| `credited` / `already_credited` | 200 | Update the balance from `balance` and return to Step 3 |
| `pending` | 200 | Retry every 3s for up to about 60s |
| `failed` | 422 | Show `message` and offer a retry |
| `forbidden` / `invalid` | 403 | Show a generic error |

---

### Step 4B — Pay with card / bank transfer (Paystack)

```
POST /public/loans/settle
{ "loan_reference": "LN-LIFVZBSIWR" }
```

`200`:

```json
{
  "authorization_url": "https://checkout.paystack.com/abc123",
  "reference": "SETTLE-LN-LIFVZBSIWR-1726300000"
}
```

1. Save `reference` — you need it to verify.
2. Open `authorization_url` in a WebView / in-app browser.
3. Paystack redirects to `https://api.nextpaydayapp.com/payment/success?reference=LN-...&type=settlement&trxref=...`. **When the WebView URL contains `/payment/success`, close it.** If the user closes the WebView manually, go to step 4 anyway.
4. Show a "Confirming your payment…" screen and poll the verify endpoint every 3s, for up to 60s:

```
GET /loans/settlement/verify?reference=SETTLE-LN-LIFVZBSIWR-1726300000
```

The server checks with Paystack directly, so it doesn't wait for the webhook. It's safe to call as often as you like.

```json
{
  "status": "settled",
  "message": "Loan settled successfully.",
  "reference": "SETTLE-LN-LIFVZBSIWR-1726300000",
  "loan_reference": "LN-LIFVZBSIWR",
  "loan_status": "paid",
  "amount": 30860.8,
  "paid_amount": 30860.8,
  "wallet_credit_amount": null
}
```

| `status` | Meaning | App action |
|---|---|---|
| `pending` | Paystack hasn't confirmed yet | Keep polling. On timeout: "Payment is being confirmed. We'll notify you." |
| `failed` | Checkout abandoned or failed | Stop polling, show `message`, and offer to try again (new `POST /public/loans/settle`) |
| `settled` | Loan is closed | Success screen. If `wallet_credit_amount` > 0, add "₦X extra was added to your wallet" |
| `credited_to_wallet` | Payment arrived but couldn't settle the loan: it was already settled, or an installment was collected meanwhile and the amount changed. **The money is in the wallet.** | Show `message`, refresh the wallet, and send the user back to the settlement sheet (they can now pay from the wallet) |
| HTTP `404` | Reference isn't this user's | Bug / wrong reference |

The Paystack webhook runs the same logic in the background, so the loan still closes if the app is killed mid-flow. Only one of the two ever processes a payment.

| Status | Meaning | App action |
|---|---|---|
| `404` | Loan already paid or not found | Refresh the loan |
| `422` | `loan_reference` invalid, or the loan can't be settled (not disbursed / nothing unpaid) | Refresh the loan |
| `500` | `{ "message": "Failed to initialize payment with Paystack" }` | "Try again later" |

---

### Step 5 — Success screen

- "Loan LN-… settled 🎉" with `amount_paid` (wallet) or the quoted `total_payable` (Paystack)
- Refresh the loans list (`GET /loans/taken`, `GET /loans/active-check`). `has_active_loan` should now be `false`, so the user can book a new loan.

---

## 5. Button cheat sheet

| Screen | Button | Shown when | Calls |
|---|---|---|---|
| Loan detail | **Settle loan** | status ∈ disbursed/defaulting/managed (or approved with a schedule) **and** at least one unpaid installment | `GET /public/loans/{ref}/settlement?user_id=` |
| Settlement sheet | **Pay ₦X from wallet** | `wallet.balance >= total_payable` | `POST /loans/settle-with-wallet` |
| Settlement sheet | **Top up ₦Y** | `wallet.balance < total_payable` | `POST /wallet/top-up` → `GET /wallet/top-up/verify` |
| Settlement sheet | **Pay with card / transfer** | always | `POST /public/loans/settle` → WebView → poll `GET /loans/settlement/verify` |

## 6. Backend guarantees

- **Checkout amount is saved.** Each Paystack checkout is recorded with its quoted amount (`loan_settlement_payments`).
- **Each payment is processed once.** The webhook and `GET /loans/settlement/verify` can both see a payment, but only the first acts.
- **Money is never lost.**
  - Payment arrives for a loan that's already settled → the full amount goes to the wallet.
  - Balance changed (an installment was collected after checkout) and the payment no longer covers it → the full amount goes to the wallet.
  - Overpayment of ₦1 or more → the loan settles and the extra goes to the wallet.
- **No double charge.** Wallet settlement and single-installment payments lock the loan / installment and the wallet row.
- **Affiliate commission.** Every settlement path (wallet, Paystack, last installment from wallet) pays the referring affiliate's completion commission, same as the admin manual settle.
- **Public endpoints stay public.** The quote and `POST /public/loans/settle` still need no token, because the admin "copy settlement link" page uses them. They only ever charge the loan's real quote.

## 7. Quick reference

```
# Loan + wallet
GET  /loans/{reference}
GET  /wallet
POST /wallet/top-up                      { "amount": number }
GET  /wallet/top-up/verify?reference=TOPUP-...

# Settlement quote
GET  /public/loans/{reference}/settlement?user_id={id}

# Settle from wallet (instant)
POST /loans/settle-with-wallet           { "loan_id": number }

# Settle with Paystack
POST /public/loans/settle                { "loan_reference": "LN-..." }
GET  /loans/settlement/verify?reference=SETTLE-...   (poll until settled / credited_to_wallet / failed)
```
