# Loan Top-Up — Borrower App E2E (Frontend Guide)

A top-up lets a borrower who has paid most of a salary loan take a new, larger loan. The new loan's
payout first pays off what the old loan still owes, the borrower receives the rest, and the old loan
closes. The new loan starts its own schedule from month 1 with the tenor the borrower picks.

Same rules as the old NextPayDay system.

Related: [borrower-book-loan-flow.md](borrower-book-loan-flow.md), [loan-settlement-flow.md](loan-settlement-flow.md).

---

## 1. Rules

| Rule | Value | Setting slug |
|---|---|---|
| Eligible after | **66.67%** (2/3) of installments paid, by count | `top_up_min_paid_percentage` |
| Max amount | The **lowest** of: average net pay × tenor × 50%; the employer max; and the largest amount (rounded down to ₦1,000) whose monthly repayment passes the 50% affordability check below | `top_up_salary_percentage` |
| Max tenor | Employer max tenor (same as a normal loan) | employer `max_month_allowed` |
| Auto-approval | Off: every top-up waits for admin review | `top_up_auto_approval_enabled` (`0`/`1`) |

The old loan must also be:
- a salary loan booked on NextPayDay (not retail, Pay Later, legacy import or Loan Manager);
  - exception: an admin can **Allow top-up** on one imported (legacy import) loan from the admin
    loan page (`POST /admin/loans/{id}/top-up-override`, revoke at `/revoke`). That lifts this rule
    and the import's collection hold for top-up only; every rule below still applies. The import
    hold stays on through the payout and is put back if the payout fails. Logged to the admin audit
    log and the borrower activity log (`LoanTopUpOverrideService`).
- `disbursed`, not `defaulting` or `managed`;
- free of overdue/failed installments and penalties;
- not on a collection hold, with no repayment in flight.

The monthly repayment must also pass the existing payout affordability check (installment ≤ 50% of
income after existing loans, with the old loan's repayment added back when the statement saw it). The
max amount already accounts for it, so any amount up to `max_amount` passes. If no amount of at least
the minimum loan amount fits, the quote refuses with "Your income on your latest bank statement does
not leave room for a top-up over N months…". If the borrower's latest statement analysis failed, the
newest successful one is used, as in the payout check; with no successful analysis there is no offer.

A new statement uploaded by a borrower who already has an offer is held for admin review
(`statement-review-pending-fe-note.md`). Until an admin approves it, top-ups are priced on the old
statement.

## 2. The money

```
what the old loan still owes = every unpaid installment in full (interest included) + any penalty
cash to the borrower         = new loan's disbursal amount (after fees) − what the old loan still owes
```

Example: old loan ₦100,000 over 4 months at ₦30,000/month, 3 paid. Top up to ₦200,000 over 6 months:
the old loan still owes ₦30,000, fees are ₦5,000, so the borrower receives **₦165,000**. The old loan
is marked paid (installment 4 recorded as `top_up`); the new loan has 6 fresh installments on ₦200,000.

Unlike early settlement, top-up does **not** waive the remaining interest.

## 3. Endpoints

### 3.1 Show the "Top up" button

```
GET /loans/{reference}/top-up
```

```json
{
  "top_up": {
    "eligible": true,
    "reason": null,
    "loan_reference": "LN-LIFVZBSIWR",
    "paid_installments": 3,
    "total_installments": 4,
    "paid_percentage": 75,
    "min_paid_percentage": 66.67,
    "payoff_amount": 30000,
    "max_tenor_months": 12,
    "salary_percentage": 50
  }
}
```

Show the **Top up** button on every repaying loan (`disbursed`, `defaulting`, `managed`). Enable it only when
`eligible` is `true`; otherwise show it **disabled** with `reason` underneath (e.g. "Top-up opens once 66.67% of
installments are paid (1 of 3 paid).").

### 3.2 Price it (amount + tenor sheet)

```
POST /loans/{reference}/top-up/quote
{ "amount": 200000, "duration_months": 6, "loan_type": "setoff" }
```

`amount` is optional. Leave it out to get the **best offer**: the quote prices the largest amount the
borrower qualifies for on that tenor (`amount` = `max_amount`, `auto_offer: true`). Show it pre-filled
and let the borrower lower it and price again. Booking (`POST /loans/drafts`) still needs an explicit
`amount`. The admin and affiliate quote endpoints work the same way.

```json
{
  "quote": {
    "top_up_of": "LN-LIFVZBSIWR",
    "employer_id": 7,
    "amount": 200000,
    "duration_months": 6,
    "loan_type": "setoff",
    "auto_offer": false,
    "max_amount": 230000,
    "offer": { "monthly_repayment": 39000, "total_repayment": 234000, "disbursal_amount": 195000, "...": "..." },
    "parent_loan_id": 42,
    "parent_reference": "LN-LIFVZBSIWR",
    "payoff_amount": 30000,
    "gross_disbursal_amount": 195000,
    "net_disbursal_amount": 165000,
    "quoted_at": "2026-10-18T09:00:00+01:00"
  }
}
```

Show `net_disbursal_amount` as "You receive", `payoff_amount` as "Pays off your current loan", and
`offer.monthly_repayment` / `duration_months` for the new loan. Nothing is saved.

`422` errors (in `errors`):

| Field | When |
|---|---|
| `top_up_of` | Loan is not eligible (message says why) |
| `amount` | Above `max_amount`; doesn't cover the old balance plus fees; net pay unknown; no amount fits half of income (no offer) |
| `duration_months` | Above the employer max tenor |
| `loan` | Another loan application is already in progress |

### 3.3 Book it

The normal salary draft flow, with one extra field:

```
POST /loans/drafts
{
  "amount": 200000, "duration_months": 6, "loan_type": "setoff",
  "employer_id": 7, "purpose": "Top up", "top_up_of": "LN-LIFVZBSIWR"
}
```

`application.top_up` carries the same payoff/net fields as the quote. Then continue exactly like a
normal booking: collection setup (`/drafts/{reference}/collection`) → `/drafts/{reference}/apply`.

## 4. What happens after apply

1. The top-up waits in admin review (auto-approval is off for top-ups by default).
2. Right before payout, the server recalculates what the old loan still owes and adjusts the payout to match.
3. When the transfer starts, automatic debits on the old loan pause.
4. When Paystack confirms the payout, the old loan is marked `paid` in the same step as the new loan
   becomes `disbursed`.
5. If the transfer fails or is reversed, the pause is lifted and the old loan carries on as before.
6. If a debit on the old loan landed during the payout, the extra amount goes to the borrower's wallet
   (`top_up_refund`).

Refresh `GET /loans/taken` after the payout: the old loan shows `paid` and the new loan `disbursed`.

## 5. Admin and affiliate

- **Admin Loans page:** the "Top-Up Eligible" box shows the count; clicking it toggles the list filter
  (`GET /admin/loans?top_up_eligible=1`, count in `top_up_eligible_count`).
- **Admin loan page:** `GET /admin/loans/{id}` returns `top_up` (eligibility, what it paid off / was closed by).
  The **Top up** button is disabled with the reason when not eligible. It prices with
  `POST /admin/loans/{id}/top-up/quote` and creates with `POST /admin/loans/{id}/top-up`
  (`amount`, `duration_months`, `loan_type`, `purpose`). If the borrower's repayment setup is live the
  application is submitted to review; otherwise it waits as a draft for the borrower. No money moves until a
  normal approval. Logged to the admin audit log and the borrower's Activity log.
- **Affiliate loan details page:** `GET /affiliate/loans/{id}` returns the loan, its schedule and `top_up`
  (same shape as admin) plus `can_create_top_up`. The **Top up** button is disabled with the reason when not
  eligible. It prices with `POST /affiliate/loans/{id}/top-up/quote` and creates with
  `POST /affiliate/loans/{id}/top-up` (same fields and behaviour as admin). Written to the borrower's
  Activity log. `GET /affiliate/borrowers/{id}` marks each repaying loan's `top_up` for an "eligible" badge.

### 5.1 Old balance: deduct or add on top (staff top-ups)

Setting `top_up_staff_balance_mode` (admin Settings → Financial → "Top-ups created by admins and
affiliates") decides what happens to what the old loan still owes on a top-up an **admin or affiliate**
creates:

| Value | The amount entered is | New loan | Borrower receives |
|---|---|---|---|
| `deduct` (default; blank or unknown also means this) | the new loan | amount | amount − fees − old balance |
| `add_on_top` | the top-up, on top of the old balance | amount + old balance | amount − fees (fees are on the whole new loan) |

- Every limit is checked on the **whole new loan**: salary × tenor × 50%, the employer max and the 50%
  affordability check. In `add_on_top`, `max_amount` is that limit minus the old balance, rounded down to
  ₦1,000; `max_loan_amount` is the limit itself.
- **Over the limit in `add_on_top` (owner decision 2026-11-02):** when an amount is entered and the whole new
  loan is above `max_loan_amount` or fails the 50% check, the top-up is **not refused**. The quote returns
  `needs_admin_override: true` and `over_limit_message`; create books it and records why in
  `meta.top_up.over_limit` (`loan_amount`, `max_loan_amount`, `emi`, `income`, `emi_to_income_percentage`,
  `fails_affordability`, `message`, `flagged_at`). Such a top-up:
  - never auto-approves, even with `top_up_auto_approval_enabled` on;
  - cannot pay out until an admin sets an **affordability override** on it from the admin loan page
    (`LoanAffordabilityOverrideService`; admins only, audited to the admin audit log and borrower Activity
    log). The pre-payout check `LoanMandateDisbursementGuard::topUpOverLimitIssue` blocks it with
    `TOP_UP_OVER_LIMIT_REVIEW_REQUIRED` otherwise, even when its repayment is under 50% of income (salary cap
    only). As with any held loan, a payout attempt before the override **declines** it;
  - loses the override if its amount, tenure or EMI change.
  Still refused: anything above the **employer max** (whole new loan), no successful statement income to judge
  by, below the minimum amount, and a quote **without** an amount (no auto offer above the limit). `deduct` mode
  and borrower-booked top-ups keep every limit as before. Create responses and logs add `needs_admin_override` /
  `over_limit`.
- Quote responses add `balance_mode`, `loan_amount` (the new loan) and `max_loan_amount`. `amount` stays
  what was entered, so screens send it back unchanged to create.
- Create accepts optional `balance_mode` (the mode the screen priced with). If the setting changed since,
  create is refused with an error on `balance_mode` and the screen prices again.
- The draft's `meta.top_up` records `balance_mode` and `requested_amount`. Payout is the same in both modes:
  right before payout the old balance is refreshed and the payout is the new loan after fees minus it.
- **Borrower-booked top-ups always deduct**, whatever the setting: the mobile app shows `amount` as the new
  loan. Only switch the borrower app over once it shows `loan_amount`.
- Changing the setting is written to the admin audit log (`update_top_up_balance_mode`); each top-up's
  admin audit and borrower Activity log entries include `balance_mode` and `loan_amount`.

## 6. Backend notes

- `loan_requests.top_up_of_loan_id` links the new loan to the loan it paid off. The old loan's
  `meta.topped_up_by` points back.
- The old loan is closed through `LoanSettlementService::settleLoan()` with method `top_up`, so it gets
  the normal activity-log entries (provider `top_up`).
- The affiliate disbursement commission on a top-up is calculated on the new cash only (amount −
  insurance − payoff). The old loan pays no completion commission.
- If the payout somehow did not cover the old loan, the old loan is **not** marked paid: it stays on
  hold with `meta.top_up_reconciliation_required` and operations is alerted.
