# PF Client Onboarding — Nextcode Integration Design

**Date:** 2026-02-13
**Status:** Design
**Author:** Claude Code + Monetarie Team
**Scope:** Core Banking — Person (PF) Onboarding with Nextcode API v4.7.0

---

## 1. Executive Summary

Design for integrating Nextcode biometric APIs into the Monetarie Core Banking onboarding flow for natural persons (PF — Pessoa Fisica). The existing 10-step frontend UI in the banking app is production-ready; this design adds the backend API layer, Nextcode integration, database persistence, and document storage to complete the end-to-end flow.

**PJ (Pessoa Juridica) onboarding is deferred** — to be designed separately.

---

## 2. Current State Assessment

### What Exists (Frontend — Complete)
- **10-step Vue 3 onboarding wizard** at `/monetarie/core/apps/banking/src/views/onboarding/`
- Views: Onboarding → Personal → Address → Occupation → Email → Phone → Document → Selfie → Review → Pending
- **Shared TypeScript types** with Zod validation (`@monetarie/shared/types`)
- State management via `useOnboarding()` composable + sessionStorage
- PrimeVue 5.x components, Tailwind CSS 4, vue-i18n (PT-BR, EN-US, ZH-CN)
- Document upload component (RG, CNH, Passport), selfie capture placeholder

### What's Missing (Backend — Zero Implementation)
- No backend onboarding/KYC API routes
- No Nextcode HTTP client integration
- No dedicated onboarding database tables (only `account_holders` exists)
- No document/file storage integration (GCS)
- No background processing (Oban) for async verification
- No admin review workflow (admin KYC views use mock data)

---

## 3. Nextcode API Integration Map

### APIs Used (5 endpoints)

| # | API | Endpoint | Purpose | When Called |
|---|-----|----------|---------|------------|
| 1 | Bureau PF | `GET /bureau/natural-person/{cpf}` | Pre-fill data from CPF | Step 2: After CPF entry |
| 2 | Full OCR v4 | `POST /full-ocr/v4?federalRevenueNumber={cpf}` | Extract + classify + validate document | Step 7: After document upload |
| 3 | Face Match v2 | `POST /face-match/v2` | Compare document face vs selfie | Step 8: After selfie capture |
| 4 | Liveness SDK | Client-side SDK (Web/Mobile) | Prove user is real person | Step 8: During selfie step |
| 5 | Face Match for Liveness | `POST /face-match-for-liveness/{requestId}` | Validate liveness result | Step 8: After liveness SDK completes |

### Authentication
- Header: `Authorization: ApiKey {NEXTCODE_API_KEY}`
- Base URLs: `https://api-homolog.nxcd.app` (dev/staging), `https://api.nxcd.app` (production)

### File Upload
- Format: `multipart/form-data` (recommended) or base64 JSON
- Accepted types: JPEG, JPG, PNG, PDF
- Max size: determined by Nextcode (we enforce 5MB client-side)

---

## 4. Architecture — Approach Selection

### Approach A: Synchronous Inline (Simple)
All Nextcode calls happen synchronously during the HTTP request. User waits for each step.
- **Pros**: Simple implementation, immediate feedback
- **Cons**: Long request times (OCR: 5-15s, Face Match: 3-8s), poor UX on slow connections, timeouts

### Approach B: Async with Oban + WebSocket (Recommended)
Nextcode calls run as Oban background jobs. Frontend receives results via Phoenix Channels (WebSocket).
- **Pros**: Responsive UI, retry on failure, audit trail, no HTTP timeouts
- **Cons**: More complex, requires WebSocket integration (but Core already has Phoenix PubSub)

### Approach C: Hybrid (Sync Bureau + Async Document/Biometrics)
Bureau PF is fast (~200ms) so runs synchronously. Document OCR and biometrics run async.
- **Pros**: Best UX — instant CPF prefill, background processing for slow operations
- **Cons**: Two patterns to maintain

**Selected: Approach C (Hybrid)** — Fast operations sync, slow operations async via Oban.

---

## 5. Detailed Design

### 5.1 Database Schema

```sql
-- New table: onboarding_applications
CREATE TABLE onboarding_applications (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  -- Identity
  cpf VARCHAR(11) NOT NULL,
  full_name VARCHAR(200),
  birth_date DATE,
  gender VARCHAR(30),
  nationality VARCHAR(60) DEFAULT 'Brasileira',
  mother_name VARCHAR(200),
  -- Address
  cep VARCHAR(8),
  street VARCHAR(200),
  street_number VARCHAR(20),
  complement VARCHAR(100),
  neighborhood VARCHAR(100),
  city VARCHAR(100),
  state VARCHAR(2),
  -- Occupation
  occupation VARCHAR(100),
  company_name VARCHAR(200),
  income_range VARCHAR(30),
  is_pep BOOLEAN DEFAULT FALSE,
  -- Contact
  email VARCHAR(200),
  email_verified BOOLEAN DEFAULT FALSE,
  phone VARCHAR(20),
  phone_verified BOOLEAN DEFAULT FALSE,
  -- Verification Results
  bureau_data JSONB,              -- Raw Bureau PF response
  bureau_fetched_at TIMESTAMPTZ,
  ocr_data JSONB,                 -- Full OCR v4 response (enhanced section)
  ocr_matches JSONB,              -- {name: bool, mothersName: bool, birthdate: bool}
  ocr_classification JSONB,       -- {type, subtype, country, confidence}
  ocr_processed_at TIMESTAMPTZ,
  face_match_confidence DECIMAL(5,2),  -- 0.00 to 100.00
  face_matched BOOLEAN,
  face_match_processed_at TIMESTAMPTZ,
  liveness_matched BOOLEAN,
  liveness_request_id UUID,
  liveness_processed_at TIMESTAMPTZ,
  -- Document Storage (GCS paths)
  document_front_path VARCHAR(500),
  document_back_path VARCHAR(500),
  selfie_path VARCHAR(500),
  cropped_face_path VARCHAR(500),  -- From Face Match v2 response
  -- Status
  status VARCHAR(30) NOT NULL DEFAULT 'draft',
    -- draft, personal_info, documents_uploaded, verifying,
    -- auto_approved, pending_review, approved, rejected
  rejection_reason TEXT,
  reviewed_by UUID REFERENCES users(id),
  reviewed_at TIMESTAMPTZ,
  -- Metadata
  nextcode_request_ids JSONB DEFAULT '[]',  -- Audit trail of all Nextcode request IDs
  ip_address INET,
  user_agent TEXT,
  metadata JSONB DEFAULT '{}',
  inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_onboarding_cpf ON onboarding_applications(cpf);
CREATE INDEX idx_onboarding_status ON onboarding_applications(status);
CREATE INDEX idx_onboarding_inserted_at ON onboarding_applications(inserted_at);

-- New table: onboarding_events (audit trail)
CREATE TABLE onboarding_events (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  application_id UUID NOT NULL REFERENCES onboarding_applications(id),
  event_type VARCHAR(50) NOT NULL,
    -- bureau_fetched, document_uploaded, ocr_completed, ocr_failed,
    -- face_match_completed, face_match_failed, liveness_completed,
    -- liveness_failed, submitted, auto_approved, sent_to_review,
    -- approved, rejected
  event_data JSONB DEFAULT '{}',
  actor_type VARCHAR(20) NOT NULL DEFAULT 'system',  -- system, user, admin
  actor_id UUID,
  inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_onboarding_events_app ON onboarding_events(application_id);
CREATE INDEX idx_onboarding_events_type ON onboarding_events(event_type);
```

### 5.2 Backend Modules (Elixir)

```
lib/monetarie/
├── onboarding/                          # New context module
│   ├── onboarding.ex                    # Public API (create, update, submit, approve, reject)
│   ├── application.ex                   # Ecto schema for onboarding_applications
│   ├── event.ex                         # Ecto schema for onboarding_events
│   ├── decision_engine.ex               # Auto-approve/review logic
│   └── validators/
│       └── cpf_validator.ex             # CPF checksum validation
├── nextcode/                            # New integration module
│   ├── client.ex                        # HTTP client (Req-based, circuit breaker)
│   ├── bureau.ex                        # Bureau PF API wrapper
│   ├── ocr.ex                           # Full OCR v4 API wrapper
│   ├── face_match.ex                    # Face Match v2 API wrapper
│   ├── liveness.ex                      # Face Match for Liveness wrapper
│   └── response_parser.ex              # Normalize Nextcode responses
lib/monetarie_web/
├── controllers/
│   └── onboarding_controller.ex         # REST API (8 endpoints)
├── channels/
│   └── onboarding_channel.ex            # WebSocket for async results
```

### 5.3 API Endpoints

| Method | Path | Action | Auth | Sync/Async |
|--------|------|--------|------|------------|
| POST | `/api/v2/onboarding` | Create application (CPF) | JWT | Sync |
| GET | `/api/v2/onboarding/:id` | Get application status | JWT | Sync |
| PUT | `/api/v2/onboarding/:id/personal` | Update personal info | JWT | Sync |
| PUT | `/api/v2/onboarding/:id/address` | Update address | JWT | Sync |
| PUT | `/api/v2/onboarding/:id/occupation` | Update occupation | JWT | Sync |
| PUT | `/api/v2/onboarding/:id/contact` | Update email + phone | JWT | Sync |
| POST | `/api/v2/onboarding/:id/document` | Upload document → OCR | JWT | Async (Oban) |
| POST | `/api/v2/onboarding/:id/selfie` | Upload selfie → Face Match + Liveness | JWT | Async (Oban) |
| POST | `/api/v2/onboarding/:id/submit` | Submit for final review | JWT | Sync |
| GET | `/api/v2/onboarding/bureau/:cpf` | Bureau PF lookup | JWT | Sync (~200ms) |

**Admin Endpoints:**

| Method | Path | Action |
|--------|------|--------|
| GET | `/api/v1/admin/onboarding` | List all applications (paginated, filterable) |
| GET | `/api/v1/admin/onboarding/:id` | Get application detail |
| POST | `/api/v1/admin/onboarding/:id/approve` | Approve application |
| POST | `/api/v1/admin/onboarding/:id/reject` | Reject with reason |

### 5.4 Onboarding Flow (Sequence)

```
User                   Banking Frontend          Core Backend           Nextcode API
  │                         │                        │                      │
  │─── Enter CPF ──────────►│                        │                      │
  │                         │──── GET /bureau/:cpf ─►│                      │
  │                         │                        │── GET /bureau/pf ───►│
  │                         │                        │◄── {name,mother} ────│
  │                         │◄── Pre-filled data ────│                      │
  │                         │                        │                      │
  │─── Fill personal ──────►│── PUT /personal ──────►│                      │
  │─── Fill address ───────►│── PUT /address ───────►│                      │
  │─── Fill occupation ────►│── PUT /occupation ────►│                      │
  │─── Verify email/phone ─►│── PUT /contact ──────►│                      │
  │                         │                        │                      │
  │─── Upload document ────►│── POST /document ────►│                      │
  │                         │                        │── Oban Job ─────────►│
  │                         │                        │   POST /full-ocr/v4  │
  │                         │                        │◄── {enhanced,matches}│
  │                         │◄── WS: ocr_completed ──│                      │
  │                         │                        │                      │
  │─── Take selfie ────────►│── POST /selfie ──────►│                      │
  │                         │                        │── Oban Job ─────────►│
  │                         │                        │   POST /face-match/v2│
  │                         │                        │◄── {matched, 97.8%} ─│
  │                         │◄── WS: face_matched ───│                      │
  │                         │                        │                      │
  │─── Liveness SDK ───────►│                        │                      │
  │    (client-side) ◄──────│                        │                      │
  │    requestId ──────────►│── POST /selfie/liveness►│                     │
  │                         │                        │── POST /face-match-  │
  │                         │                        │   for-liveness/{id}  │
  │                         │                        │◄── {matched: true} ──│
  │                         │◄── WS: liveness_ok ────│                      │
  │                         │                        │                      │
  │─── Review + Submit ────►│── POST /submit ──────►│                      │
  │                         │                        │── Decision Engine ───│
  │                         │                        │   auto_approve? ─────│
  │                         │◄── approved/pending ───│                      │
```

### 5.5 Decision Engine (Auto-Approve Criteria)

An application is **auto-approved** when ALL conditions are met:

| Criterion | Threshold | Source |
|-----------|-----------|--------|
| Bureau PF data matches personal info | All fields match | `ocr_matches.name = true` AND `ocr_matches.birthdate = true` |
| OCR classification confidence | >= 0.95 | `ocr_classification.sides[0].confidence` |
| Face Match confidence | >= 95.0% | `face_match_confidence >= 95.0` |
| Liveness validated | true | `liveness_matched = true` |
| CPF valid checksum | true | `CpfValidator.valid?/1` |
| No PEP flag | false | `is_pep = false` |
| Document type recognized | CNH or RG | `ocr_classification.type in ["DriversLicense", "FederalID"]` |

If ANY condition fails → **sent_to_review** (manual admin review required).

### 5.6 Nextcode Client (Elixir)

```elixir
defmodule Monetarie.Nextcode.Client do
  @moduledoc "HTTP client for Nextcode API v4.7.0"

  @default_timeout 30_000  # 30s for OCR/biometrics

  def bureau_pf(cpf) do
    get("/bureau/natural-person/#{cpf}")
  end

  def full_ocr_v4(file_binary, filename, cpf) do
    post_multipart("/full-ocr/v4", [
      {:file, file_binary, filename}
    ], params: %{federalRevenueNumber: cpf})
  end

  def face_match_v2(document_binary, doc_filename, selfie_binary, selfie_filename) do
    post_multipart("/face-match/v2", [
      {:file, document_binary, "documento", doc_filename},
      {:file, selfie_binary, "selfie", selfie_filename}
    ])
  end

  def face_match_for_liveness(request_id) do
    post("/face-match-for-liveness/#{request_id}")
  end

  # Private — uses Req with circuit breaker
  defp get(path), do: request(:get, path)
  defp post(path), do: request(:post, path)

  defp request(method, path) do
    Req.request(
      method: method,
      url: base_url() <> path,
      headers: [{"authorization", "ApiKey #{api_key()}"}],
      receive_timeout: @default_timeout,
      retry: :transient,
      max_retries: 2
    )
  end

  defp base_url, do: Application.get_env(:monetarie, :nextcode_api_url)
  defp api_key, do: Application.get_env(:monetarie, :nextcode_api_key)
end
```

### 5.7 Environment Variables

| Variable | Dev Default | Description |
|----------|-----------|-------------|
| `NEXTCODE_API_URL` | `https://api-homolog.nxcd.app` | Nextcode API base URL |
| `NEXTCODE_API_KEY` | (from Nextcode) | API key for authentication |
| `NEXTCODE_ENABLED` | `false` | Feature flag to enable/disable |
| `NEXTCODE_TIMEOUT_MS` | `30000` | HTTP request timeout |
| `ONBOARDING_AUTO_APPROVE` | `true` | Enable auto-approval engine |
| `FACE_MATCH_THRESHOLD` | `95.0` | Minimum face match confidence % |

### 5.8 Oban Workers

| Worker | Queue | Args | Retry |
|--------|-------|------|-------|
| `Monetarie.Workers.OcrVerification` | `:nextcode` | `{application_id, file_path}` | 3 attempts, exponential backoff |
| `Monetarie.Workers.FaceMatchVerification` | `:nextcode` | `{application_id, doc_path, selfie_path}` | 3 attempts |
| `Monetarie.Workers.LivenessVerification` | `:nextcode` | `{application_id, request_id}` | 3 attempts |

### 5.9 WebSocket Channel

```elixir
# Topic: "onboarding:{application_id}"
# Events pushed to client:
#   - "bureau_fetched" — {name, mother_name, birth_date}
#   - "ocr_completed" — {classification, matches, enhanced_data}
#   - "ocr_failed" — {error, retry_available}
#   - "face_match_completed" — {matched, confidence}
#   - "face_match_failed" — {error}
#   - "liveness_completed" — {matched}
#   - "liveness_failed" — {error}
#   - "decision" — {status: "auto_approved" | "pending_review"}
```

### 5.10 Frontend Changes (Banking App)

| File | Change |
|------|--------|
| `PersonalView.vue` | Add CPF blur → fetch Bureau PF → auto-fill fields |
| `DocumentView.vue` | Wire upload to `POST /onboarding/:id/document`, show OCR results via WS |
| `SelfieView.vue` | Integrate Nextcode Liveness SDK (Web), wire to face match + liveness endpoints |
| `ReviewView.vue` | Show verification badges (Bureau, OCR match, Face Match %, Liveness) |
| `PendingView.vue` | Show auto-approved vs pending-review status |
| `useOnboarding.ts` | Add WebSocket subscription for async results, API service calls |

### 5.11 Security Considerations

- **API Key Storage**: `NEXTCODE_API_KEY` in K8s secret, never exposed to frontend
- **File Upload**: 5MB limit client-side, 10MB server-side, image type validation
- **CPF Validation**: Server-side checksum + uniqueness check before Bureau API call
- **Rate Limiting**: Max 3 onboarding attempts per CPF per 24h
- **Data Retention**: Nextcode request IDs stored for audit; raw images deleted after 90 days per LGPD
- **Encryption**: Document images encrypted at rest in GCS (Google-managed keys)
- **LGPD Compliance**: Explicit consent checkbox before biometric data collection

---

## 6. Implementation Plan (8 Steps)

| Step | Scope | Files | Estimated |
|------|-------|-------|-----------|
| 1 | **DB Migration** — onboarding_applications + onboarding_events tables | 1 migration | Quick |
| 2 | **Ecto Schemas** — Application + Event schemas with changesets | 2 files | Quick |
| 3 | **Nextcode Client** — HTTP client + 4 API wrappers + response parser | 6 files | Medium |
| 4 | **Onboarding Context** — CRUD operations + decision engine | 3 files | Medium |
| 5 | **API Controller + Routes** — 10 user + 4 admin endpoints | 2 files + router | Medium |
| 6 | **Oban Workers** — OCR, Face Match, Liveness background jobs | 3 files | Medium |
| 7 | **WebSocket Channel** — onboarding:{id} topic with 7 events | 1 file + socket | Quick |
| 8 | **Frontend Wiring** — Connect existing views to new API + Liveness SDK | 6 files | Medium |

---

## 7. Testing Strategy

| Layer | Approach |
|-------|----------|
| **Unit** | ExUnit tests for Nextcode client (mock HTTP), decision engine logic, CPF validation |
| **Integration** | Nextcode homolog API calls with test documents (provided by Nextcode support) |
| **E2E** | Cypress/Playwright flow: CPF → Personal → Document → Selfie → Submit |
| **Load** | K6 test: 100 concurrent onboardings (Bureau PF + OCR calls) |

---

## 8. Risks & Mitigations

| Risk | Impact | Mitigation |
|------|--------|------------|
| Nextcode API downtime | Onboarding blocked | Circuit breaker + retry + graceful degradation (allow manual review) |
| OCR accuracy on low-quality images | False rejections | Retry with image quality guidance, fallback to manual review |
| Liveness SDK browser compatibility | Some users can't complete | Fallback to simple selfie + manual biometric review |
| High latency on OCR (>15s) | Poor UX | Async processing + progress indicators + WebSocket notifications |
| LGPD data retention | Compliance risk | 90-day auto-purge of biometric images, anonymized audit trail |

---

## 9. Dependencies

| Dependency | Status | Action |
|-----------|--------|--------|
| Nextcode API key (homolog) | Needed | Request from Nextcode support |
| Nextcode Liveness SDK (Web) | Available | `github.com/nextcodebr/liveness-sdk-web-sample` |
| Oban (Elixir job queue) | In Core mix.exs | Already a dependency |
| GCS bucket for documents | Needed | Create `monetarie-onboarding-docs` bucket in `southamerica-east1` |
| Phoenix Channels (WebSocket) | Core has PubSub | Add onboarding channel to existing socket |
