diff --git a/docs/kyc-bulk-import-design.md b/docs/kyc-bulk-import-design.md new file mode 100644 index 0000000..bd14216 --- /dev/null +++ b/docs/kyc-bulk-import-design.md @@ -0,0 +1,453 @@ +# KYC Bulk Import — Design Document + +**Status:** Draft +**Author:** TBD +**Last updated:** 2026-07-28 + +--- + +## Table of Contents + +1. [Purpose and Scope](#1-purpose-and-scope) +2. [Workflow Overview](#2-workflow-overview) +3. [CSV / Template Format Spec](#3-csv--template-format-spec) +4. [Validation Rules](#4-validation-rules) +5. [Preview Rules](#5-preview-rules) +6. [Review-Before-Signing Assumptions](#6-review-before-signing-assumptions) +7. [Error Handling](#7-error-handling) +8. [Security Risks](#8-security-risks) +9. [Open Questions](#9-open-questions) + +--- + +## 1. Purpose and Scope + +This document defines the **design contract** for the KYC whitelist bulk import feature. It covers the full intended workflow from file upload through to audit-logged state changes. + +### What this document IS + +- A specification and design contract +- The canonical reference for TypeScript interfaces (`bulkImport.types.ts`) +- The source of truth for validation rules, preview behaviour, and the mandatory human review gate + +### What this document IS NOT + +- An implementation guide (no upload UI, parser, or signing endpoint is defined here) +- A regulatory or legal compliance opinion + +### Why this feature is needed + +The existing `BulkComplianceReview` component operates on pre-loaded, in-memory subjects and applies changes immediately upon a user action. There is no defined workflow for importing a batch of new KYC status changes from an external source (e.g. a KYC provider), no validation of the incoming data, and critically no enforced maker-checker review before state is committed. This design addresses all three gaps. + +--- + +## 2. Workflow Overview + +### Step-by-step sequence + +``` +┌─────────────┐ ┌──────────────────┐ ┌──────────────────────┐ +│ UPLOADER │ │ SYSTEM │ │ APPROVER (different │ +│ (issuer / │ │ (frontend + │ │ person from │ +│ admin) │ │ backend) │ │ uploader) │ +└──────┬──────┘ └────────┬─────────┘ └──────────┬───────────┘ + │ │ │ + │ 1. Upload CSV │ │ + │────────────────────>│ │ + │ │ │ + │ 2. Schema check │ │ + │ (columns, │ │ + │ encoding, │ │ + │ row count) │ │ + │<────────────────────│ │ + │ │ │ + │ 3. Data validation │ │ + │ (IDs, dupes, │ │ + │ transitions) │ │ + │<────────────────────│ │ + │ │ │ + │ 4. Preview diff │ │ + │ (current → │ │ + │ proposed per │ │ + │ row; read-only) │ │ + │<────────────────────│ │ + │ │ │ + │ 5. Uploader │ │ + │ submits for │ │ + │ review │ │ + │────────────────────>│ │ + │ │ 6. Notify approver │ + │ │─────────────────────────> │ + │ │ │ + │ │ ╔═══════════════════╗ │ + │ │ ║ HUMAN REVIEW ║ │ + │ │ ║ GATE (no state ║ │ + │ │ ║ change has ║ │ + │ │ ║ occurred yet) ║ │ + │ │ ╚═══════════════════╝ │ + │ │ │ + │ │ 7. Approver reviews │ + │ │ preview diff, │ + │ │ approves or rejects │ + │ │<─────────────────────────│ + │ │ │ + │ 8. Approval logged │ │ + │ (identity + │ │ + │ timestamp + │ │ + │ signature) │ │ + │<────────────────────│ │ + │ │ │ + │ 9. Apply batch: │ │ + │ write each row │ │ + │ to store / │ │ + │ contract │ │ + │<────────────────────│ │ + │ │ │ + │ 10. Audit log: │ │ + │ per-row │ │ + │ before/after, │ │ + │ immutable │ │ + │<────────────────────│ │ +``` + +### Key constraint: the human review gate + +**No batch may transition from `pending_review` to `approved` / `applied` without an explicit approval action performed by a human who is different from the person who uploaded the file.** This is the maker-checker principle and is non-negotiable. Steps 1–5 are preparatory and non-committing. The first state change happens in step 9, and only after step 7 has been completed. + +--- + +## 3. CSV / Template Format Spec + +### 3.1 Encoding and delimiter + +| Property | Required value | +|-----------------|----------------------------| +| Encoding | UTF-8 (no BOM) | +| Delimiter | Comma (`,`) | +| Line ending | LF (`\n`) or CRLF (`\r\n`) | +| Quote character | Double-quote (`"`) | +| First row | Header row (column names) | + +### 3.2 Column specification + +Columns **must appear in this exact order**. Extra columns to the right are **ignored** (forward-compatibility rule — see §3.5). + +| # | Column name | Type | Required | Allowed values | Notes | +|---|---------------------|---------|----------|-----------------------------------------------------|------------------------------------------| +| 1 | `schema_version` | string | Yes | `1.0` | Must match current template version | +| 2 | `account_id` | string | Yes | Stellar address (G…, 56 chars) or stable entity ID | Primary key; identifies the subject | +| 3 | `action` | string | Yes | `approve`, `reject`, `flag_review`, `remove` | What to do to this subject | +| 4 | `proposed_status` | string | Yes | `approved`, `rejected`, `review`, `pending` | Target status after the action is applied| +| 5 | `jurisdiction` | string | No | ISO 3166-1 alpha-2 code (e.g. `US`, `EU`, `SG`) | Blank = unchanged | +| 6 | `tier` | string | No | `retail`, `accredited`, `professional`, `unknown` | Blank = unchanged | +| 7 | `reason_code` | string | No | Free text, max 120 chars | Human-readable reason; ends up in audit | +| 8 | `effective_date` | string | No | ISO 8601 date (`YYYY-MM-DD`) | When the status should take effect | +| 9 | `external_ref` | string | No | Free text, max 80 chars | KYC provider reference / case ID | + +### 3.3 Required vs optional summary + +- **Required:** `schema_version`, `account_id`, `action`, `proposed_status` +- **Optional:** `jurisdiction`, `tier`, `reason_code`, `effective_date`, `external_ref` + +Optional columns that are blank or absent will leave the corresponding field unchanged on the subject record. + +### 3.4 Constraints + +| Constraint | Value | Justification | +|-------------------------|--------------|----------------------------------------------------------------------| +| Max rows per upload | 500 | Limits blast radius of a compromised or mistaken upload | +| Max file size | 2 MB | Prevents DoS via large file parsing | +| Max batches per hour | 5 per user | Rate-limits automated/scripted abuse | + +### 3.5 Versioning + +The `schema_version` column is the versioning mechanism. + +- **Current version:** `1.0` +- **Adding a new optional column** in a future version: increment to `1.1`. Old `1.0` templates remain valid because extra columns are appended to the right and missing optional columns are treated as blank. +- **Removing or reordering a column, or changing a required column's allowed values**: increment major version to `2.0`. The parser must reject `1.x` files with a clear message: _"This template is version 1.x. Please download and use the current 2.0 template."_ +- Templates must self-describe their version. A file with no `schema_version` column is rejected with a schema error. + +--- + +## 4. Validation Rules + +Validation happens in two ordered phases. Phase 1 (schema) must pass before Phase 2 (data) runs. + +### 4.1 Schema-level checks (Phase 1) + +These are **hard rejects** — the entire batch is blocked. They are hard rejects because the file is structurally unusable; partial processing would be meaningless. + +| Rule ID | Description | +|-----------|--------------------------------------------------------------------------| +| `S-001` | File encoding is UTF-8 | +| `S-002` | Delimiter is comma | +| `S-003` | First row is a header row with all required column names present | +| `S-004` | `schema_version` column value matches a supported version (`1.0`) | +| `S-005` | File has at least 1 data row (after header) | +| `S-006` | File has no more than 500 data rows | +| `S-007` | File is no larger than 2 MB | +| `S-008` | `proposed_status` values are within the allowed set | +| `S-009` | `action` values are within the allowed set | +| `S-010` | `effective_date`, if present, is a valid ISO 8601 date | + +### 4.2 Data-level checks (Phase 2) + +These operate row by row. Each check is either a **hard reject** (blocks the entire batch) or a **soft warning** (row is flagged but the batch can proceed). + +#### Hard rejects (block entire batch) + +| Rule ID | Description | Why hard? | +|-----------|------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------| +| `D-001` | `account_id` is non-empty | A row without an ID cannot be applied to anything | +| `D-002` | No two rows in the same file share the same `account_id` | Duplicate rows introduce ambiguity about final state | +| `D-003` | `action` and `proposed_status` are internally consistent (e.g. `action=approve` requires `proposed_status=approved`) | Inconsistency signals a malformed or tampered file | +| `D-004` | Uploader's own `account_id` is not present in the batch | Self-whitelisting prevention (see §8) | +| `D-005` | Batch does not contain more than 50% `action=approve` rows without at least one `reason_code` present | Bulk blind-approvals without justification are flagged at batch level | + +#### Soft warnings (row flagged, batch can proceed) + +| Rule ID | Description | Why soft? | +|-----------|------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------| +| `W-001` | `account_id` does not match any known subject in the current dataset | May be a new subject being added; approver should confirm | +| `W-002` | `proposed_status` is the same as the current status (no-op row) | Not an error, but wasteful; worth surfacing to the approver | +| `W-003` | Status transition is unusual (e.g. `approved` → `approved` is no-op; `rejected` → `approved` skips `review`) | Transitions from `rejected` directly to `approved` bypass review; flag for human attention | +| `W-004` | `jurisdiction` value is not in the expected ISO 3166-1 list | Could be a typo; approver should verify | +| `W-005` | `effective_date` is in the past | Not invalid, but may indicate a stale or replayed file | +| `W-006` | `reason_code` is absent on a `reject` action | Rejections without a reason are permitted but should be reviewed | +| `W-007` | Row count exceeds 100 (within the 500 hard limit) | Large batches warrant extra scrutiny; surfaced as a batch-level warning | + +### 4.3 Transition validity matrix + +| Current status | Proposed status | Result | +|----------------|-----------------|-----------| +| `pending` | `approved` | OK | +| `pending` | `rejected` | OK | +| `pending` | `review` | OK | +| `approved` | `rejected` | W-003 | +| `approved` | `review` | OK | +| `approved` | `approved` | W-002 | +| `rejected` | `approved` | W-003 | +| `rejected` | `review` | OK | +| `review` | `approved` | OK | +| `review` | `rejected` | OK | +| Any | same as current | W-002 | + +--- + +## 5. Preview Rules + +### 5.1 What the preview must show + +The preview is generated after all validation passes (or after warnings are acknowledged). It is **read-only and non-committing** — no state change occurs during preview generation or display. + +**Per-row diff:** + +Each row in the preview table must show: + +| Column | Description | +|-------------------|-------------------------------------------------------------------| +| Account ID | Truncated (first 8 + last 4 chars), full value in tooltip/title | +| Current status | Live value from the current dataset (highlighted if missing) | +| → Proposed status | Value from the CSV, styled by direction (green=approve, red=reject, amber=flag) | +| Action | Human-readable description of the action being applied | +| Jurisdiction | Current → proposed (if changed; blank if unchanged) | +| Tier | Current → proposed (if changed; blank if unchanged) | +| Warnings | Inline badge for any W-xxx warnings on this row | +| Row status | `valid`, `warning`, or `error` (hard errors should not reach preview) | + +**Aggregate summary (top of preview):** + +``` +12 approve | 3 reject | 2 flag for review | 1 remove +4 warnings | 0 errors | 500 → 492 net valid rows +``` + +### 5.2 Visual distinction rules + +- Rows with warnings (`W-xxx`) must be visually distinguishable from clean rows — e.g. amber left border or amber row background. +- Rows flagged with hard errors (should not appear in preview, but defensive) must use a red left border. +- Status change direction should use the same colour system as `ComplianceBadge` already used in the codebase (`emerald` for approved, `rose` for rejected, `sky` for review, `amber` for pending). + +### 5.3 Interactivity constraints + +- The preview table is **entirely read-only**. Reviewers cannot edit values inline. +- No approve/reject/submit buttons are active until the uploader explicitly confirms submission for review. +- The "Submit for review" action transitions the batch from `draft` → `pending_review` and notifies the approver. **No compliance data changes at this point.** + +--- + +## 6. Review-Before-Signing Assumptions + +### 6.1 Maker-checker principle (non-negotiable) + +> **No batch may be applied without an explicit human approval step after preview. The person who approves must be different from the person who uploaded the file.** + +This is enforced at the application layer. The system must: + +1. Record the `uploader_id` (wallet address + authenticated role) at upload time. +2. When an approver attempts to approve, compare `approver_id` to `uploader_id`. If they match, the approval must be rejected with a clear error: _"The uploader and approver must be different people."_ +3. The approval action must be logged with `approver_id`, `approved_at` timestamp, and a reference to the batch ID. + +### 6.2 Roles + +Based on the existing `DashboardRole` system: + +| Action | Required role | Notes | +|--------------|-------------------------|-------------------------------------------------| +| Upload CSV | `admin` or `issuer` | `issuer` may upload for assets they manage | +| Review/Approve | `admin` | Approval is a privileged action | +| Reject batch | `admin` | — | +| View preview | `admin` or `issuer` | Uploaders can see their own drafts | +| View audit log | `admin`, `read_only` | Audit log is always readable by admins | + +> ⚠️ **Open question OQ-01:** Should `issuer` be allowed to upload batches for any subject, or only for subjects associated with their own issued assets? See §9. + +### 6.3 What "signing" means — assumption to confirm + +This system does not yet have a defined signing mechanism for batch operations. Three options are possible, and the team must confirm which applies: + +**Option A — DB-level approval record only** +An approval record is written to the database with `approver_id`, `batch_id`, and `approved_at`. No cryptographic signature. Simplest to implement, weakest non-repudiation. + +**Option B — Freighter wallet signature** +The approver signs a canonical representation of the batch (e.g. SHA-256 of the batch JSON) using their connected Stellar wallet via `@stellar/freighter-api`. The resulting signature is stored alongside the approval record. Stronger non-repudiation; requires the approver to have their wallet connected. + +**Option C — On-chain transaction** +Each apply operation is submitted as a Soroban contract call, making every state change an on-chain transaction. This gives the strongest audit trail (immutable, publicly verifiable) but is the most expensive and slowest. + +> ⚠️ **Open question OQ-02:** Which signing mechanism (A, B, or C) should be used? This document assumes **Option B as a target**, with Option A as an acceptable interim, but the team must confirm. See §9. + +Until confirmed, no signing implementation should be built. The `BatchStatus` type in `bulkImport.types.ts` reserves space for a `signatureRef` field regardless of mechanism. + +--- + +## 7. Error Handling + +### 7.1 Invalid row policy: whole-batch vs. partial + +**Decision: hard errors block the whole batch; soft warnings do not.** + +Rationale: + +- Hard errors (schema failures, self-whitelisting, duplicate IDs) indicate the file itself is suspect or malformed. Applying partial data from a suspect file risks introducing inconsistency and obscures the problem. The uploader should fix the file and re-upload. +- Soft warnings indicate rows that are unusual but not necessarily wrong. They should be visible in the preview so the approver can make an informed decision. The approver may choose to exclude flagged rows before approving, but the system does not auto-exclude them. + +> ⚠️ **Open question OQ-03:** Should the approver be able to manually exclude individual flagged rows from a batch during review, or must they reject the whole batch and ask for a corrected file? See §9. + +### 7.2 Error communication to the uploader + +| Error type | Delivery mechanism | +|---------------|----------------------------------------------------------------------| +| Schema errors | Inline error banner at the top of the upload step, before preview is shown | +| Data errors | Listed in the preview under a collapsible "Errors" section; entire batch is blocked | +| Row warnings | Inline per-row badge in the preview table | +| Batch-level warnings | Summary chip above the preview table | +| Downloadable error report | Available for batches with > 10 errors, as a CSV: columns `row_number`, `account_id`, `rule_id`, `severity`, `message` | + +### 7.3 Partial-apply failures + +A partial-apply failure occurs when the batch has been approved but a DB or contract write fails mid-batch (e.g. row 40 of 100 fails). + +**Policy:** + +1. The apply operation is transactional. If a transactional DB is available, wrap the entire batch in a single transaction and roll back on any failure. +2. If a transactional DB is not available (e.g. on-chain, where atomicity per-transaction applies), the system must: + a. Record which rows were successfully applied and which failed. + b. Transition the batch to `partially_applied` status. + c. Notify both the uploader and approver with a report of which rows failed and why. + d. The remaining failed rows must **not** be retried automatically. A new batch should be created for the failed rows after investigating the cause. +3. The audit log entry for the batch must record `rows_applied`, `rows_failed`, and the failure reason for each failed row. + +> ⚠️ **Open question OQ-04:** Is transactional atomicity available in the target persistence layer? See §9. + +--- + +## 8. Security Risks + +### 8.1 Malicious CSV / self-whitelisting + +**Threat:** An uploader includes their own `account_id` in the batch with `action=approve` to whitelist themselves. + +**Mitigation:** Rule `D-004` hard-rejects any batch that contains the uploader's own address. This check is performed server-side (the client check is UX-only and must not be the sole defence). + +### 8.2 Privilege escalation via CSV + +**Threat:** A malicious CSV attempts to set `proposed_status=approved` for accounts that should remain `rejected`, or uses crafted `reason_code` values to inject content into audit logs. + +**Mitigation:** +- `reason_code` and `external_ref` fields are treated as plain text; they are HTML-escaped before display and stripped of any executable content before storage. +- The allowed values for `action` and `proposed_status` are an allowlist enforced by rule `S-008`/`S-009`. Values outside the allowlist are a hard schema reject. +- All values are validated against the allowlist server-side before any write. + +### 8.3 Replay of a previously-used file + +**Threat:** An attacker replays a legitimate, previously-approved file to re-apply the same whitelist changes (e.g. re-approving accounts that have since been rejected for cause). + +**Mitigation:** +- Each uploaded file is hashed (SHA-256) at upload time and the hash is stored in the batch record. +- Before accepting a new upload, the system checks whether a batch with the same hash has been processed in the last 30 days. If found, the upload is rejected with: _"This file has already been used in batch [ID]. Please create a new file."_ +- The `effective_date` field is validated; a `W-005` warning is raised if it is in the past, prompting the approver to consider whether the batch is stale. + +### 8.4 Mass incorrect status changes from a compromised uploader + +**Threat:** A compromised `issuer` or `admin` account uploads a batch that incorrectly rejects or removes a large number of valid accounts. + +**Mitigations:** +- **Max batch size** of 500 rows limits the blast radius. +- **Rate limiting**: max 5 batches per user per hour. +- **Maker-checker**: the approver (a different person) must review the diff before any change is applied. A large batch with many `reject` actions should be visually obvious in the preview summary. +- **Audit trail**: every change is logged with before/after values and identities. Incorrect changes can be identified and reversed by creating a corrective batch. +- Rule `D-005` flags batches where > 50% of rows are blind approvals without reason codes. + +### 8.5 Required audit trail + +The audit log for each batch must be **append-only and immutable** (no update or delete operations on audit records). Each batch audit record must contain: + +| Field | Description | +|------------------------|-------------------------------------------------------------------| +| `batch_id` | Unique identifier for the batch | +| `uploaded_by` | Wallet address / user ID of the uploader | +| `uploaded_at` | ISO 8601 timestamp | +| `approved_by` | Wallet address / user ID of the approver | +| `approved_at` | ISO 8601 timestamp | +| `signature_ref` | Signature or approval record reference (mechanism TBD per OQ-02) | +| `file_hash` | SHA-256 of the original uploaded file | +| `row_count` | Total rows in the batch | +| `rows_applied` | Count of successfully applied rows | +| `rows_failed` | Count of rows that failed to apply | +| `per_row_log` | Array: `{ account_id, status_before, status_after, applied_at, failure_reason? }` | + +The per-row log must record both the `status_before` (live at apply time, not at upload time) and `status_after`. This ensures the log reflects the actual change made, not just the intended change. + +### 8.6 RBAC assumptions + +See §6.2 for the role-to-action mapping. Additionally: + +- API endpoints for upload, preview retrieval, and approval must enforce role checks server-side. Client-side role gating (e.g. `RouteGuard`) is UX-only. +- The `read_only` role must not be able to upload or approve batches. +- The `investor` role must not have access to bulk import endpoints at all. + +### 8.7 Rate limiting and batch size limits + +| Limit | Value | Enforcement layer | +|-----------------------------|-------|-----------------------| +| Max rows per batch | 500 | Schema check S-006 | +| Max file size | 2 MB | Schema check S-007 | +| Max batches per user/hour | 5 | Server-side rate limiter | +| Max concurrent `pending_review` batches per user | 3 | Application logic | + +--- + +## 9. Open Questions + +The following assumptions must be confirmed with the team before implementation begins. They are flagged here rather than assumed to avoid building on a wrong foundation. + +| ID | Question | Impact if wrong assumption is made | +|--------|---------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------| +| OQ-01 | Should the `issuer` role be able to upload batches for **any** subject, or only subjects associated with their own assets? | If issuers have global upload access, a compromised issuer account has wider blast radius | +| OQ-02 | Which signing mechanism applies: DB-level approval record (A), Freighter wallet signature (B), or on-chain transaction (C)? | Determines whether `signatureRef` holds a DB ID, a Stellar signature, or a tx hash | +| OQ-03 | Should approvers be able to exclude individual flagged rows from a batch during review, or must they reject the whole batch? | Affects `BatchPreviewRow` type (needs an `excluded` flag) and approval flow complexity | +| OQ-04 | Is transactional atomicity available in the target persistence layer (i.e. can a multi-row batch be rolled back if one row fails)? | Determines whether `partially_applied` batch status is possible or whether it is all-or-nothing | +| OQ-05 | What is the maximum age of a `pending_review` batch before it expires? (e.g. auto-reject after 72 hours?) | Determines whether an `expires_at` field is needed on `KycBatchRecord` | +| OQ-06 | Should `action=remove` remove the subject from the whitelist entirely, or just set their status to a terminal rejected state? | Affects what the per-row audit log records and whether the subject record is deleted or updated | diff --git a/docs/kyc-bulk-import-template.csv b/docs/kyc-bulk-import-template.csv new file mode 100644 index 0000000..a803c50 --- /dev/null +++ b/docs/kyc-bulk-import-template.csv @@ -0,0 +1,4 @@ +schema_version,account_id,action,proposed_status,jurisdiction,tier,reason_code,effective_date,external_ref +1.0,GBKP7K3F6XN3V9QZ2WY8L4M1T5R6S7A8B9C0D1E2F3G4H5I6J7K8L9M0N1O2P,approve,approved,US,accredited,KYC documents verified by compliance desk,2026-07-28,KYC-2026-001234 +1.0,GAAQ1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V2W3X4Y5Z6A7B8C9D0E1F,flag_review,review,EU,retail,Accreditation certificate expires 2026-08-15 — renewal pending,2026-07-28,KYC-2026-001235 +1.0,INVALID_ADDRESS_FORMAT,approve,approved,XX,,,,KYC-2026-001236 diff --git a/src/components/README-bulk-import-notes.md b/src/components/README-bulk-import-notes.md new file mode 100644 index 0000000..fcc3f09 --- /dev/null +++ b/src/components/README-bulk-import-notes.md @@ -0,0 +1,141 @@ +# Bulk Import — Review UI Notes + +**Scope:** Notes for whoever builds the preview/review component. +This is NOT a component implementation. No JSX, no hooks, no logic here. +See `docs/kyc-bulk-import-design.md` for the full workflow spec and +`src/features/compliance/bulkImport.types.ts` for all type contracts. + +--- + +## What the preview table must show + +Each row in the preview table corresponds to one `BatchPreviewRow` from the +`BatchPreview` type. The table must display: + +| Column | Content | +|------------------|----------------------------------------------------------------------------| +| Row # | 1-based CSV row number (`rowNumber`). Helps uploaders locate errors in the original file. | +| Account ID | Shortened display form (`accountIdDisplay`). Full address in `title` attribute and `aria-label`. | +| Current status | `statusDiff.current` — use `ComplianceBadge` colour tokens. Show "—" if null (new subject). | +| → Proposed | `statusDiff.proposed` — visually distinct from current (arrow or chevron separator). | +| Change direction | `statusDiff.changed` flag: highlight only rows where a real change occurs. No-op rows (W-002) should be de-emphasised, not hidden. | +| Action | `actionLabel` text (e.g. "Approve", "Reject", "Flag for review", "Remove"). | +| Jurisdiction | `jurisdictionDiff` — show "current → proposed" only when `changed: true`. | +| Tier | `tierDiff` — same pattern as jurisdiction. | +| Warnings | One badge per `ValidationWarning` in `warnings[]`. Show rule ID + short message. | +| Row status | Drives row-level visual treatment (see Colour System below). | + +**Aggregate summary bar** (rendered above the table, from `BatchPreviewSummary`): + +``` +X approve | Y reject | Z flag for review | N remove +W warnings | 0 errors | net N rows +``` + +Always show all four action counts even if zero. Approvers must see the full +scope of a batch at a glance. + +--- + +## Colour system for row status + +Use the same Tailwind tokens already established in `BulkComplianceReview`: + +| `rowStatus` | Left border / row bg | Notes | +|--------------|-------------------------------------|-------------------------------------------| +| `valid` | No special treatment | Clean rows | +| `warning` | `border-l-4 border-amber-400` | Amber left border, `bg-amber-50` on hover | +| `error` | `border-l-4 border-rose-500` | Should not reach preview; defensive only | + +Status badges: +- `approved` → `bg-emerald-100 text-emerald-800` +- `rejected` → `bg-rose-100 text-rose-800` +- `review` → `bg-sky-100 text-sky-800` +- `pending` → `bg-amber-100 text-amber-800` + +--- + +## Approve / reject button states + +Both buttons live outside the table and act on the **entire batch**, not +individual rows. + +### Approve button + +| Condition | State | +|----------------------------------------------|------------------------| +| `BatchPreview.canSubmitForReview === false` | `disabled` | +| Any unresolved hard error (errorCount > 0) | `disabled` | +| Approver identity === uploader identity | `disabled` + tooltip: "You cannot approve your own batch" | +| Batch status is not `pending_review` | `disabled` | +| All above clear | enabled | + +### Reject button + +| Condition | State | +|----------------------------------------------|------------------------| +| Batch status is not `pending_review` | `disabled` | +| Approver identity === uploader identity | `disabled` + tooltip: "You cannot reject your own batch" | +| All above clear | enabled (always rejectable if status is correct) | + +Both buttons must require an explicit confirmation step (e.g. a modal or +inline confirmation) before firing. Irreversible actions must not be a +single click. + +The "Submit for review" button (uploader side) is disabled until +`canSubmitForReview === true`. + +--- + +## Accessibility notes for large diffs + +Large batches (100–500 rows) place specific demands on keyboard and screen +reader users. + +### Table + +- Use a proper `` with `` / `` — not a `
` grid. +- Every `
` elements + when row count exceeds 50, so screen readers can announce position. +- Warn the user visually (and in an `aria-live` region) when a batch exceeds + 100 rows (rule W-007), so they are not surprised by a long review task. + +### Confirmation modal (approve/reject) + +- Focus must move into the modal on open. +- Pressing Escape must close the modal without confirming. +- The confirm button must not be the first focused element — require a + deliberate Tab before the destructive action is reachable. +- After closing, focus must return to the button that opened the modal. + +### Colour contrast + +- All badge text must meet WCAG 2.1 AA (4.5:1 for normal text). +- The amber warning row treatment must not be the only indicator — + add a warning icon (`⚠` with `aria-hidden="true"`) alongside the colour. + +--- + +## What this component must NOT do + +- Must not commit any compliance data changes (no API calls, no store writes). +- Must not allow inline editing of any cell value. +- Must not auto-approve or auto-apply on any user action short of explicit + multi-step confirmation. +- Must not show the approve button to the same person who uploaded the batch. diff --git a/src/features/compliance/bulkImport.types.ts b/src/features/compliance/bulkImport.types.ts new file mode 100644 index 0000000..61f9c20 --- /dev/null +++ b/src/features/compliance/bulkImport.types.ts @@ -0,0 +1,381 @@ +/** + * bulkImport.types.ts + * + * TypeScript type contracts for the KYC whitelist bulk import workflow. + * + * IMPORTANT: This file contains ONLY interfaces, types, and enums. + * No functions, no API calls, no state logic. + * + * These types are the authoritative machine-readable companion to + * docs/kyc-bulk-import-design.md. Any changes to the CSV spec or + * validation rules must be reflected here and vice-versa. + * + * All column names match the CSV spec in §3.2 of the design doc exactly. + */ + +import type { DashboardRole } from '@/features/auth/types'; + +// --------------------------------------------------------------------------- +// CSV schema constants (mirrors §3.2 and §3.3 of the design doc) +// --------------------------------------------------------------------------- + +/** Supported CSV schema versions. Reject any file whose schema_version is not in this union. */ +export type CsvSchemaVersion = '1.0'; + +/** Actions the CSV may request on a subject. Maps to BulkAction in complianceReview. */ +export type CsvImportAction = 'approve' | 'reject' | 'flag_review' | 'remove'; + +/** + * KYC status values that may appear in proposed_status. + * Kept in sync with ComplianceStatus in src/lib/complianceReview.ts. + */ +export type KycComplianceStatus = 'approved' | 'rejected' | 'review' | 'pending'; + +/** Tier values permitted in the tier column. */ +export type SubjectTier = 'retail' | 'accredited' | 'professional' | 'unknown'; + +// --------------------------------------------------------------------------- +// 1. Raw parsed CSV row shape +// Represents one data row exactly as it comes out of the CSV parser — +// all fields are strings (or undefined for missing optional columns). +// No validation has been applied yet. +// --------------------------------------------------------------------------- + +export interface RawCsvRow { + /** Column 1 — must equal a supported CsvSchemaVersion */ + schema_version: string; + /** Column 2 — Stellar address or stable entity ID */ + account_id: string; + /** Column 3 — what to do to this subject */ + action: string; + /** Column 4 — target status after the action is applied */ + proposed_status: string; + /** Column 5 — ISO 3166-1 alpha-2 jurisdiction code (optional) */ + jurisdiction?: string; + /** Column 6 — subject tier (optional) */ + tier?: string; + /** Column 7 — human-readable reason, max 120 chars (optional) */ + reason_code?: string; + /** Column 8 — ISO 8601 date YYYY-MM-DD (optional) */ + effective_date?: string; + /** Column 9 — KYC provider reference / case ID, max 80 chars (optional) */ + external_ref?: string; + /** 1-based position in the file (after header). Used for error reporting. */ + _rowNumber: number; +} + +// --------------------------------------------------------------------------- +// 2. Validated row shape (post-validation) +// Produced only when a row passes all hard-reject and schema checks. +// Fields have been coerced to their correct types. +// --------------------------------------------------------------------------- + +export interface ValidatedCsvRow { + /** Stable subject identifier (verified non-empty, format-checked). */ + accountId: string; + /** Parsed and validated action. */ + action: CsvImportAction; + /** Parsed and validated proposed status. */ + proposedStatus: KycComplianceStatus; + /** Validated ISO 3166-1 alpha-2 code, or undefined if absent. */ + jurisdiction?: string; + /** Parsed tier, or undefined if absent. */ + tier?: SubjectTier; + /** Sanitised reason code (HTML-escaped, length-checked), or undefined. */ + reasonCode?: string; + /** Parsed Date object from effective_date, or undefined. */ + effectiveDate?: string; // kept as ISO string; caller constructs Date if needed + /** Sanitised external reference, or undefined. */ + externalRef?: string; + /** Original 1-based row number for traceability back to the source file. */ + rowNumber: number; + /** Any soft warnings (W-xxx rule IDs) triggered by this row. */ + warnings: ValidationWarning[]; +} + +// --------------------------------------------------------------------------- +// 3. Preview diff shape +// One entry per validated row in the preview table. +// Shows current (live) value → proposed value so the approver can see +// exactly what will change. Read-only; does not commit anything. +// --------------------------------------------------------------------------- + +/** Visual row status in the preview table. */ +export type PreviewRowStatus = 'valid' | 'warning' | 'error'; + +/** A single field-level diff: what is the value now vs what the CSV proposes. */ +export interface FieldDiff { + current: T | null; // null means the field has no current value (new subject) + proposed: T; + changed: boolean; // true when current !== proposed +} + +/** The full diff for one row in the preview table. */ +export interface BatchPreviewRow { + /** Truncated account ID for display (full value in accountIdFull). */ + accountIdDisplay: string; + /** Full account ID for tooltip / aria-label. */ + accountIdFull: string; + /** Status diff: current live status → proposed status from CSV. */ + statusDiff: FieldDiff; + /** Jurisdiction diff (only populated when the CSV provides a value). */ + jurisdictionDiff?: FieldDiff; + /** Tier diff (only populated when the CSV provides a value). */ + tierDiff?: FieldDiff; + /** Human-readable action label derived from CsvImportAction. */ + actionLabel: string; + /** Raw action value for programmatic use. */ + action: CsvImportAction; + /** Soft warnings on this row (W-xxx). */ + warnings: ValidationWarning[]; + /** Overall row status for visual styling in the preview table. */ + rowStatus: PreviewRowStatus; + /** Original 1-based row number for download error reports. */ + rowNumber: number; + /** + * Whether the approver has manually excluded this row from the batch. + * Only relevant if OQ-03 is resolved in favour of per-row exclusion. + * @see docs/kyc-bulk-import-design.md §9 OQ-03 + */ + excluded?: boolean; +} + +/** Aggregate summary shown above the preview table. */ +export interface BatchPreviewSummary { + totalRows: number; + approveCount: number; + rejectCount: number; + flagReviewCount: number; + removeCount: number; + warningCount: number; + /** Always 0 in a valid preview (hard errors block preview generation). */ + errorCount: number; + /** Rows that will actually be applied (total minus excluded, if OQ-03 permits exclusion). */ + netApplicableRows: number; +} + +/** The full preview payload returned to the UI. */ +export interface BatchPreview { + batchId: string; + summary: BatchPreviewSummary; + rows: BatchPreviewRow[]; + /** ISO 8601 timestamp when this preview was generated. */ + generatedAt: string; + /** + * True only when all hard errors are resolved and the batch is safe to + * submit for review. Approve/reject buttons must remain disabled until + * this is true. + */ + canSubmitForReview: boolean; +} + +// --------------------------------------------------------------------------- +// 4. Batch status enum +// Tracks the lifecycle of a submitted batch from upload to audit log. +// --------------------------------------------------------------------------- + +/** + * Lifecycle states of a KYC bulk import batch. + * + * draft → Uploaded, validation passed, preview generated. + * No state change has occurred. Uploader can discard. + * pending_review → Uploader submitted for review. Awaiting approver action. + * Still no compliance data change. + * approved → Approver approved the batch (maker-checker complete). + * Apply operation is queued or in progress. + * rejected → Approver rejected the batch. No changes applied. + * Uploader must create a new batch. + * applying → Apply operation is in progress (rows being written). + * applied → All rows successfully written. Audit log complete. + * partially_applied→ Apply completed but some rows failed. See audit log. + * @see docs/kyc-bulk-import-design.md §7.3 + * expired → Batch aged out of pending_review without approver action. + * @see docs/kyc-bulk-import-design.md §9 OQ-05 + * failed → Apply operation failed before any rows were written. + */ +export type BatchStatus = + | 'draft' + | 'pending_review' + | 'approved' + | 'rejected' + | 'applying' + | 'applied' + | 'partially_applied' + | 'expired' + | 'failed'; + +/** The persisted record for a KYC bulk import batch. */ +export interface KycBatchRecord { + batchId: string; + status: BatchStatus; + /** Wallet address of the person who uploaded the file. */ + uploadedBy: string; + /** Role of the uploader at time of upload. */ + uploaderRole: DashboardRole; + /** ISO 8601 timestamp of upload. */ + uploadedAt: string; + /** SHA-256 hex digest of the original uploaded file. Used for replay detection. */ + fileHash: string; + /** Original filename as provided by the browser (for display only). */ + originalFilename: string; + /** Parsed schema version from the file. */ + schemaVersion: CsvSchemaVersion; + /** Total data rows in the file. */ + rowCount: number; + /** + * Identity of the approver, set when status transitions to `approved` or `rejected`. + * Must differ from `uploadedBy` (maker-checker rule). + */ + approvedBy?: string; + /** ISO 8601 timestamp of the approval or rejection decision. */ + approvedAt?: string; + /** + * Reference to the approval signature or DB approval record. + * Exact format depends on the signing mechanism resolved in OQ-02. + * @see docs/kyc-bulk-import-design.md §6.3 + */ + signatureRef?: string; + /** Number of rows successfully written during apply. */ + rowsApplied?: number; + /** Number of rows that failed during apply. */ + rowsFailed?: number; + /** + * ISO 8601 timestamp after which a pending_review batch auto-expires. + * Only present if OQ-05 is resolved with a defined TTL. + * @see docs/kyc-bulk-import-design.md §9 OQ-05 + */ + expiresAt?: string; +} + +// --------------------------------------------------------------------------- +// 5. Validation error / warning shape +// --------------------------------------------------------------------------- + +/** Severity of a validation finding. Hard errors block the batch; warnings do not. */ +export type ValidationSeverity = 'error' | 'warning'; + +/** + * Hard-reject rule IDs (schema-level, §4.1) and data-level (§4.2). + * Any finding with one of these IDs blocks the entire batch. + */ +export type HardRejectRuleId = + | 'S-001' // Encoding not UTF-8 + | 'S-002' // Delimiter not comma + | 'S-003' // Missing required header columns + | 'S-004' // Unsupported schema_version + | 'S-005' // No data rows + | 'S-006' // Exceeds 500-row limit + | 'S-007' // Exceeds 2 MB file size limit + | 'S-008' // proposed_status value not in allowlist + | 'S-009' // action value not in allowlist + | 'S-010' // effective_date not valid ISO 8601 + | 'D-001' // account_id is empty + | 'D-002' // Duplicate account_id in same file + | 'D-003' // action / proposed_status internally inconsistent + | 'D-004' // Uploader's own account_id present in batch (self-whitelisting) + | 'D-005' // > 50% approve rows with no reason_code in batch + ; + +/** + * Soft-warning rule IDs (§4.2). + * Findings with these IDs are surfaced to the approver but do not block the batch. + */ +export type SoftWarningRuleId = + | 'W-001' // account_id not found in current dataset (may be new) + | 'W-002' // No-op: proposed_status equals current status + | 'W-003' // Unusual status transition (e.g. rejected → approved) + | 'W-004' // jurisdiction not in expected ISO 3166-1 list + | 'W-005' // effective_date is in the past + | 'W-006' // reject action has no reason_code + | 'W-007' // Row count exceeds 100 (batch-level warning) + ; + +export type ValidationRuleId = HardRejectRuleId | SoftWarningRuleId; + +/** A single validation finding, attached to either the batch or a specific row. */ +export interface ValidationFinding { + ruleId: ValidationRuleId; + severity: ValidationSeverity; + /** Human-readable explanation of why this rule fired. */ + message: string; + /** + * 1-based row number this finding relates to, or undefined for batch-level findings. + * Matches RawCsvRow._rowNumber and ValidatedCsvRow.rowNumber. + */ + rowNumber?: number; + /** The account_id value of the offending row, if applicable. */ + accountId?: string; +} + +/** A finding with severity === 'warning'. Type guard alias for clarity. */ +export type ValidationWarning = ValidationFinding & { severity: 'warning' }; + +/** A finding with severity === 'error'. Type guard alias for clarity. */ +export type ValidationError = ValidationFinding & { severity: 'error' }; + +/** The complete result returned after running all validation phases. */ +export interface ValidationResult { + /** True only when there are zero hard errors (batch can proceed to preview). */ + isValid: boolean; + /** All findings, errors and warnings combined. */ + findings: ValidationFinding[]; + /** Convenience: findings where severity === 'error'. */ + errors: ValidationError[]; + /** Convenience: findings where severity === 'warning'. */ + warnings: ValidationWarning[]; + /** Rows that passed validation (empty if isValid is false). */ + validatedRows: ValidatedCsvRow[]; + /** + * For download error reports: the same findings as a CSV-serialisable shape. + * Only populated when errors.length > 10 (per §7.2 of the design doc). + */ + errorReportRows?: ErrorReportRow[]; +} + +/** One row in the downloadable error report CSV (§7.2). */ +export interface ErrorReportRow { + row_number: number; + account_id: string; + rule_id: ValidationRuleId; + severity: ValidationSeverity; + message: string; +} + +// --------------------------------------------------------------------------- +// 6. Audit log shape (§8.5) +// --------------------------------------------------------------------------- + +/** Per-row entry in the immutable audit log written after apply. */ +export interface AuditRowEntry { + /** Subject identifier. */ + accountId: string; + /** + * Status at the moment the apply operation ran (not at upload time). + * null if the subject did not exist before this batch. + */ + statusBefore: KycComplianceStatus | null; + /** Status written by this batch. null if the row failed to apply. */ + statusAfter: KycComplianceStatus | null; + /** ISO 8601 timestamp when this individual row was applied. */ + appliedAt?: string; + /** Reason the row failed to apply, if applicable. */ + failureReason?: string; +} + +/** The complete, immutable audit record for a batch. */ +export interface KycBatchAuditRecord { + batchId: string; + uploadedBy: string; + uploadedAt: string; + approvedBy: string; + approvedAt: string; + /** Signing mechanism reference — format TBD per OQ-02. */ + signatureRef: string; + /** SHA-256 hex digest of the original file, for replay detection. */ + fileHash: string; + rowCount: number; + rowsApplied: number; + rowsFailed: number; + /** Per-row log. Must be append-only and never mutated after creation. */ + perRowLog: AuditRowEntry[]; +}
` must have `scope="col"`. +- Each row's Account ID cell must have a `title` and `aria-label` containing + the full address (`accountIdFull`), since the displayed value is truncated. +- The warning badge for each row should include an `aria-label` that reads + the full warning message, not just the rule ID code. + +### Summary bar + +- Mark it as a `
` or equivalent landmark. +- Counts must be readable without colour — don't rely on colour alone to + distinguish approve vs reject vs warning counts. + +### Large table navigation + +- Provide a "Jump to first warning" keyboard shortcut or focusable anchor + so reviewers can skip straight to flagged rows. +- Consider adding `aria-rowcount` and `aria-rowindex` on `