# Weekly offer

A salary borrower whose bank statement shows salary in too few months is not given a monthly loan.
Their application is declined when they submit it, and they are emailed a **one-month loan repaid in
weekly installments** instead. They answer on a web page; the mobile app is not involved.

Code: `WeeklyOfferService`, `WeeklyOfferSettings`, `WeeklyLoanOffer`, `WeeklyOfferPageController`,
`resources/views/weekly-offer/*`, `emails/weekly_offer`. Tests: `tests/Feature/WeeklyOfferTest.php`.

## Switches (all off by default)

- Global: `weekly_offer_enabled` (Settings → Financial → Weekly offer).
- Per employer: `employers.weekly_offer_enabled` (Employers → Weekly Offer). Salary employers only.

Both must be on.

## The rule

`SalaryLoanDraftService::apply()` runs every normal check first (mandate latest-only, Paystack DD,
processing fee). Then, for new salary applications only (not top-ups, retail, Loan Manager, imports or
partner credit), it reads the newest successful `bank_statement_analyses` row that predates the
application:

- `months_analyzed < weekly_offer_min_salary_months` (default 3 of 6) → weekly offer.
- No analysis, or `months_analyzed` 0 → **no** weekly offer; the application continues normally.

When the rule fires, inside the same transaction: the monthly application becomes `declined`
(`meta.declined_by_type = system`, `meta.weekly_offer`), the Mono activation is **not** marked used,
the processing fee stays consumed by this application and carries over, and any older open offer for
the borrower is closed. No auto-approval runs. This system decline does not start the 24h cool-off.

## Offer amount

- Income = salary after existing loan repayments. With `weekly_offer_income_basis = total_over_window`
  (default) it is the total ÷ the statement window (at least 6 months); with
  `average_of_salary_months` it is the average of the months paid. Admin income add-backs are included.
- Price for the whole loan = employer `weekly_offer_rate` / `weekly_offer_fee` (% for the month), else
  the employer's `rate_3` / `fees_3`. Same `LoanCalculator`, one period; insurance and set-off/capitalize
  as usual.
- Amount = min(amount asked, employer `weekly_offer_max_amount`, income × `weekly_offer_max_income_percentage`
  (employer override, default 50) ÷ price factor, the application's own total ÷ price factor), rounded
  down to ₦1,000. Below `minimum_loan_application_amount` → plain decline, no offer.
- Installments: total ÷ `weekly_offer_weeks` (default 4), to the kobo, remainder on the last one.

## Borrower answers

Link: `/weekly-offer/{token}` (64 random characters; stored as sha256 for lookup and encrypted for the
email; never in in-app, push or communication logs). Valid for `weekly_offer_expiry_hours` (72).

- **Approve** re-checks under locks: account active, booking on (global and employer), weekly offer still
  on, no open loan, Paystack DD complete, and the offer's Mono activation is still the newest live one.
  Then it creates a `pending` loan with `repayment_frequency = weekly`, `duration_weeks`,
  `duration_months = 1`, `emi` = weekly installment, on the same mandate, and runs
  `SalaryLoanDraftService::finishSubmission()` (the same auto-approval as every application).
  Accepting twice creates one loan. A failed re-check leaves the offer open.
- **Decline** closes it. `loans:expire-weekly-offers` (hourly) closes expired ones. Admins can withdraw an
  open offer (`POST /admin/weekly-offers/{id}/cancel`, audited).

## After payout

- Schedules (`SalaryLoanDisbursementService::finalizeDisbursement`): due 7, 14, 21, 28 days after the
  money left (Lagos), all `pending`. **Payroll grace never applies** to weekly loans.
- Collection: the normal due-date collectors; nothing weekly-specific.
- 50% check (`LoanMandateDisbursementGuard::affordabilityFigures`): for weekly loans it compares what is
  due within a month (the whole total for a ≤5-week loan) to the weekly-offer income and limit. Used by
  auto-approval, payout and the review checklist.
- Early settlement divides principal by the real installment count (`LoanRequest::installmentCount`).
- The 7-day reminder is skipped for weekly loans.

## Audit

Borrower Activity log: `weekly_offer_created`, `_not_offerable`, `_viewed`, `_accepted`, `_accept_blocked`,
`_declined`, `_expired`, `_superseded`, admin `weekly_offer_cancelled`. Admin audit log:
`settings.weekly_offer_changed`, `employer.weekly_offer_changed`, `weekly_offer.cancelled`.
