# Duplicate identity checks (one person, one borrower profile)

People opened several profiles with different BVNs (some genuinely theirs, some rented) and
got several loans approved. BVN uniqueness alone cannot stop that, so the API now looks for
**other things the profiles share** and holds both profiles until an admin decides.

## What is compared

Stored as keyed HMAC hashes (`BVN_HASH_KEY`) in `identity_signals`; raw values are never copied.

| Signal | Source | Level |
|---|---|---|
| `bvn` | existing `users.bvn_hash` (catches old duplicates) | hold |
| `phone` | `users.phone`, last 10 digits (`+234…` = `0…`) | hold |
| `email` | `users.email`, lower-case, `+tag` removed, Gmail dots removed | hold |
| `payout_account` | every `bank_details` row (bank code + account number) | hold |
| `payroll_id` | employer + IPPIS number / payroll ID | hold |
| `name_dob` | profile name words (any order) + date of birth | hold |
| `selfie_image` | SHA-256 of the selfie file (exact same photo) | hold |
| `face` | face embedding similarity ≥ `FACE_MATCH_THRESHOLD` (self-hosted face service) | hold |
| `nin`, `bvn_name_dob` | Zeeh BVN record, only when the paid Zeeh check is on | hold |
| `device` | `X-Device-Id` header (stage two, selfie) | **flag only**: affiliates onboard many borrowers on one phone |

Placeholder values (all one digit, `0123456789`, `N/A`) are ignored. A value shared by more
than 10 profiles (an office phone, imported dummy data) is only flagged.

Pairs live in `identity_matches` (`user_id` < `matched_user_id`, one row per signal). A pair
is recorded once; a cleared pair is never reopened by a later scan.

## Where it is enforced

`IdentityBookingGate` (same pattern as `EmployerBookingGate`):

- **Booking**: the 13 borrower booking routes (middleware `EnsureIdentityClear`, next to
  `EnsureEmployerBookingEnabled`), affiliate booking payment, affiliate top-up quote and
  top-up. The WhatsApp web wizard (`book`) uses the same borrower routes. Admin flows
  (Loan Manager, admin top-up) never call it, like the employer switch.
- **Payout backstop**: `LoanMandateDisbursementGuard::check()` (salary bank and wallet
  payouts, all callers) and `RetailDisbursementService::attemptDisbursement()`. These only
  read stored matches; they make no lookups inside the payout transaction.

Answers (HTTP 422 at booking):

| Code | When |
|---|---|
| `SELFIE_REQUIRED` | `identity_selfie_required` on and no passed selfie (none, rejected or error) |
| `IDENTITY_REVIEW_REQUIRED` | newest selfie in review, or an open **hold** match (with holds on) |
| `IDENTITY_DUPLICATE_CONFIRMED` | an admin confirmed a match: always blocks, switches or not |
| `IDENTITY_CHECK_UNAVAILABLE` | the check failed (e.g. `BVN_HASH_KEY` missing) while holds are on |

At the salary payout guard an identity block behaves like every other guard failure: the
application is declined with the reason saved (pre-disbursement failures do **not** start
the decline cool-off). It only happens when a matching profile appeared after booking. The
retail payout returns `ready: false, reason: identity_review_required` and changes nothing.

## Selfie

`POST /profile/identity-check/selfie` (see `mobile-identity-selfie-guide.md`).
`FaceVerificationService`:

1. Saves the image on the private `local` disk (`identity/selfies/{user}/…`).
2. Self-hosted face service (`face-service/`, free MIT models): face count, anti-spoof
   (`real`), liveness (`live`), embedding. 0 or 2+ faces → `rejected`; low real/live →
   `review`; service down or not configured → `error` (never a pass).
3. Optional, paid: Zeeh "BVN with face" (`identity_zeeh_face_match_enabled`). Zeeh fetches
   the selfie from a 10-minute signed URL. No match or confidence below
   `identity_zeeh_min_confidence` → `review`; any failure → `review`. Never retried.
4. Records signals and compares the embedding with every other borrower's newest selfie.

Every selfie is written to the borrower activity log (`selfie_verification`).

### Selfie by link (borrower's own phone)

When the affiliate is on a laptop or the borrower is elsewhere, the affiliate taps **Send link to
borrower's phone**, copies the link and pastes it into WhatsApp or SMS.

- `POST /profile/identity-check/selfie-link` (borrower token; the affiliate uses the onboarding
  token) → `{url, expires_at}`. The URL is `BOOK_WEB_BASE_URL/selfie/{token}`. A new link kills
  the borrower's older unused links; no link once the selfie has passed (`SELFIE_ALREADY_CONFIRMED`).
- `GET /profile/identity-check/selfie-links/{token}` (public) → borrower's first name and selfie
  status only: never the profile, `can_book` or any identity block.
- `POST /profile/identity-check/selfie-links/{token}/selfie` (public, multipart `selfie`,
  header `X-Device-Id`) → the same checks as any selfie, saved with `source = link`. The link is
  used up once a selfie passes or goes to review; a rejected or "could not check" selfie can be
  retaken on the same link. Shares the daily selfie limit.
- Links live 30 minutes; only the SHA-256 of the token is stored. Expired, used, replaced or
  unknown links answer `410 SELFIE_LINK_EXPIRED`.
- Activity log: `selfie_link_created`, `selfie_link_used`, plus the usual `selfie` verification row.

The link is for convenience and a better device signal; it does not stop an agent opening it
themselves. The face match across all borrowers is what catches one face behind many profiles.

## Admin

Admin app → **Identity Review** (`/identity-reviews`), permission `borrowers`:

- Holds / flags / decided matches, with both profiles side by side.
- **Different people** = clear (hold lifts, never reopened). **Same person** = confirm (both
  profiles blocked from new loans; can be undone by clearing).
- Selfies in review: view (each view audit-logged), approve or ask for a retake.
- `GET /admin/users/{id}/identity`: signals present, matches, selfies and the cluster of
  linked profiles with their loan counts.

Every decision: `admin_action_logs` (`identity.match_cleared|confirmed`,
`identity.selfie_approved|rejected`, `identity.selfie_viewed`) **and** each borrower's
activity log. A reason is required.

## Settings (Settings → Financial)

| Slug | Catalog default (missing row) | Seeded by the migration |
|---|---|---|
| `identity_duplicate_hold_enabled` | `1` (strict) | `0` |
| `identity_selfie_required` | `1` (strict) | `0` |
| `identity_zeeh_face_match_enabled` | `0` (paid) | `0` |
| `identity_zeeh_min_confidence` | `80` | — |

The migration seeds the two switches **off** so deploying does not hold existing borrowers
or block booking before the mobile app has the selfie screen. While off, matches are still
recorded and listed for review.

## Rollout (owner)

1. Deploy the face service (see `face-service/README.md`); set `FACE_SERVICE_URL`,
   `FACE_SERVICE_SECRET` in the API env.
2. Deploy the API (migration `2026_11_01_200000_create_identity_dedupe_tables`: new tables
   and three settings rows only).
3. Run `php artisan identity:scan-duplicates` (writes only `identity_signals` /
   `identity_matches`). Review the Holds tab; clear the false positives.
4. Deploy admin, affiliate and book. Ship the mobile selfie screen.
5. Turn on `identity_duplicate_hold_enabled`, then `identity_selfie_required`.
6. Optional: fund Zeeh and turn on `identity_zeeh_face_match_enabled`; confirm the
   production base URL and the BVN-with-face product on your Zeeh app first.

Turning holds on also holds **existing** borrowers in a matched pair, including approved
loans not yet paid out (the payout guard declines them with the reason). Review step 3 first.

## Flows and callers checked

- `LoanMandateDisbursementGuard::check()`: one new check first (after the borrower exists).
  Callers: `SalaryLoanDisbursementService` (wallet, bank, manual, retry paths, in and out of
  the payout transaction), unchanged otherwise. `affordabilityIssue()` /
  `affordabilityFigures()` (used by drafts, top-ups, overrides) are untouched.
- `RetailDisbursementService::attemptDisbursement()`: new not-ready reason after the
  security check. Callers: Monnify webhook (returns quietly), admin retail release (422
  with the message), retail setup.
- `routes/api/loans.php`: middleware added after the employer middleware on its 13 routes.
- `AffiliateBookingPaymentController`, `AffiliateController` (top-up quote, top-up): one
  gate call after the employer gate. `resume`, WhatsApp magic link and AI-lead OTP are not
  gated, so an affiliate can still open the borrower to take the selfie.
- `ProfileController::verifyStageTwo`: records signals after the existing action; the
  stage-two response is unchanged. `VerifyStageTwoAction` (incl. the production-only
  duplicate-BVN block) is unchanged.
- Partner credit (`PartnerBorrowerIdentityService`) is unchanged: it already blocks
  duplicate BVNs everywhere and has its own disbursement path.
