# Bilko Feature — Document Inbox / Ulaz dokumenata (MC #104515)

# Bilko — Document Inbox / Ulaz dokumenata (MC #104515)

**Status: BUILT AND LIVE on `azdo/main`** (verified 2026-08-08 by reading `apps/api` and `apps/web` at the tip of `azdo/main`, commit `88769328`). This page documents the actual shipped implementation — not a plan. The original MC #104515 gap audit (2026-06-29) flagged Document Inbox as "MISSING"; it shipped afterward across three merges:

| MC | What shipped | Merge |
|---|---|---|
| #104515 (Phase 1) | Capture-first upload → pending review queue → book as expense / reject | PR 30, `fee37dc8` |
| #104519 | Proveo route-layer test coverage for Phase 1 | same PR |
| #105687 | Third terminal status `archived` — permanent document archive (`/dokumenti`) for documents that never become an expense | `e2ee2a4a` |
| #106195 | UX redesign of the inbox/`ulazni-racuni` screens | `88769328` |

## 1. Concept

Two distinct product surfaces share one table (`inbox_items`):

- **`/inbox`** ("Ulaz dokumenata") — the active review queue. A user uploads a receipt/invoice scan *before* any accounting record exists. Each upload becomes a `pending` row. From there it is either **booked** (creates an `Expense` and links back), **rejected** (discarded, reason optional), or **archived** (see below).
- **`/dokumenti`** ("Arhiva dokumenata", MC #105687) — permanent archive for documents that will *never* become an expense: contracts, bank statements, delivery notes (*otpremnica*), insurance policies. Modeled as a third terminal status on the same row rather than a new table (see design rationale in the V122 migration, §2).

This is deliberately distinct from `ExpenseDocuments` (V40), which is attach-to-an-*existing*-expense, and from `ReceivedEInvoices` (V139), which is the Storecove e-invoice webhook capture (has OIB/UBL fields, no OCR).

## 2. Schema

Table `inbox_items`, defined in `V106__document_inbox.sql` (Phase 1) and widened by `V122__inbox_archive_documents.sql` (archive feature). Kotlin Exposed object: `InboxItems` in `apps/api/src/main/kotlin/no/alai/bilko/models/Tables.kt`.

**V106 — base table:**
- Identity: `id` (UUID PK), `org_id` (FK → `organizations`, `ON DELETE CASCADE`)
- Storage: `storage_url`, `storage_key`, `original_filename`, `content_type`, `file_size`, `checksum_sha256`, `storage_backend` (`r2` \| `local` \| `unknown`) — mirrors the `ExpenseDocuments` / `ReceiptService.uploadDocument` storage pattern
- Lifecycle: `status` (`pending` \| `booked` \| `rejected`, widened to add `archived` in V122), `uploaded_by`
- **Phase 2 OCR fields (nullable, reserved, NOT populated by Phase 1 code):** `extracted_amount NUMERIC(19,4)`, `extracted_currency CHAR(3)`, `extracted_date DATE`, `extracted_vendor VARCHAR(500)`, `extracted_vat NUMERIC(19,4)`, `ocr_confidence NUMERIC(5,4)` (checked `0.0000–1.0000`) — the migration header states these are for **Azure Document Intelligence**, deferred.
- Booking linkage: `booked_expense_id`, `booked_invoice_id`, `booked_at`, `booked_by`
- Rejection: `rejection_reason`, `rejected_at`, `rejected_by`
- Audit: `created_at`, `updated_at`
- Constraints: `status` check, `file_size > 0`, `storage_backend` allowlist, `ocr_confidence` range check
- Indexes: `(org_id, status)`, `(org_id, created_at DESC)`, `(uploaded_by)`
- **RLS:** `ENABLE ROW LEVEL SECURITY` + `FORCE ROW LEVEL SECURITY`, policy `org_isolation` scopes every row to `current_setting('app.current_org_id')::uuid` for role `bilko_app` — same pattern as `expense_documents` (V40).

**V122 — archive extension** (adds the `archived` outcome, MC #105687):
- Widens the `status` CHECK to include `archived`
- New columns: `document_type` (nullable, enum-checked only when populated: `contract`\|`statement`\|`delivery_note`\|`insurance_policy`\|`other`), `contact_id` (nullable FK → `contacts`, `ON DELETE SET NULL`), `tags TEXT[]` (default `'{}'`), `archived_at`, `archived_by`
- New indexes: partial index on `archived_at` where `status='archived'`, partial index on `document_type`, index on `contact_id`, GIN index on `tags`
- **Design decision (documented in the migration header):** extend `inbox_items` rather than create a new `archived_documents` table, to avoid duplicating storage/RLS/index plumbing. `booked_expense_id` is reused (not status-changing) for the "naknadno vezanje" (late-link) flow — e.g. an *otpremnica* archived first, linked to an expense once the *račun* arrives later.
- **Retention is explicitly NOT enforced** in this migration — HR *knjigovodstvene isprave* retention (an 11-year candidate) needs validation with the `bilko-racunovodstvo-hr` domain expert before any auto-deletion ships. No expiry logic exists today.
- RLS needs no change — row-scoped policy from V106 automatically covers new columns.

## 3. Routes

Defined in `apps/api/src/main/kotlin/no/alai/bilko/routes/InboxRoutes.kt`, wired in `Routing.kt` via `inboxRoutes()` and `documentsRoutes()`, service layer `InboxService.kt` (DI singleton in `DI.kt`).

**`inboxRoutes()` — mounted under `/inbox`:**
| Method | Path | Purpose |
|---|---|---|
| GET | `/inbox/count` | Pending badge count (dashboard bell + sidebar) — registered *before* `/{id}` to avoid Ktor trie ambiguity |
| GET | `/inbox` | Paginated list; query params `status`, `page`, `perPage` |
| POST | `/inbox` | Multipart upload → creates a `pending` item |
| GET | `/inbox/{id}` | Detail for the review screen |
| POST | `/inbox/{id}/book` | Creates an `Expense` in the same transaction, transitions row to `booked`, best-effort attaches the original scan to the new expense via `ExpenseService.attachDocument` |
| POST | `/inbox/{id}/reject` | Transitions to `rejected`, optional `{ "reason": string }` |
| POST | `/inbox/{id}/archive` | Transitions to `archived` (V122); body: `documentType` (required), `contactId` (optional), `tags` (optional) |

**`documentsRoutes()` — mounted under `/documents` (backs the `/dokumenti` screen, MC #105687):**
| Method | Path | Purpose |
|---|---|---|
| GET | `/documents` | Paginated, filterable list of `archived` items (`documentType`, `contactId`, `tag`, `dateFrom`, `dateTo`) |
| POST | `/documents/{id}/link-expense` | Late-link an already-archived document to an `Expense` created separately (body: `{ "expenseId": string }`) |

**Upload security pipeline** (POST `/inbox`, in order): permission check *before* multipart parse → `UploadSecurityGate.authorizeActor` (tenant-bound actor check) → MIME allowlist (`application/pdf`, `image/jpeg`, `image/jpg`, `image/png`) → 20 MB hard cap → empty-file guard → the client-declared `Content-Type` header check here is a cheap early rejection only (attacker-controlled) — the authoritative control is magic-byte content sniffing + ClamAV malware scan + persisted quarantine/scan-provenance state machine at the shared `ReceiptService.uploadObject` choke point (`UploadSecurityGate`, MC #106852 G1-02), which also gates expense-attach, invoice-receipt, and support-ticket-attachment uploads. The route additionally hard-fails closed (compensates/deletes the stored object) if scan provenance (`scanAttemptId`, `scanState == "RELEASED"`, `scanEngine`, `scanEngineVersion`, `scannedAt`) is incomplete after upload — an inbox row is never created for an object without a verified clean-scan verdict.

## 4. RBAC

Role hierarchy (`RbacHelper.kt`): `viewer` (0) < `accountant` (1) < `admin` (2) < `owner` (3). Permission catalog + role grants seeded in `V67__rbac_permissions_catalog.sql`.

| Route | Permission | Roles that hold it |
|---|---|---|
| `GET /inbox/count`, `GET /inbox`, `GET /inbox/{id}`, `GET /documents` | `expense:read` | viewer, accountant, admin, owner |
| `POST /inbox` (upload), `POST /inbox/{id}/book` | `expense:create` | accountant, admin, owner |
| `POST /inbox/{id}/reject`, `POST /inbox/{id}/archive`, `POST /documents/{id}/link-expense` | `expense:categorize` | accountant, admin, owner |

No new permission keys were introduced for Document Inbox — it reuses the existing `expense:*` catalog, treating booking/rejecting/archiving as expense-adjacent classification actions. `viewer` role can browse the inbox and archive but cannot upload, book, reject, or archive.

## 5. Frontend

- `apps/web/app/(dashboard)/inbox/page.tsx` — list/queue screen: drag-and-drop or file-picker upload (PDF/JPEG/PNG, 20 MB cap), status tabs (`pending`/`booked`/`rejected` — `archived` intentionally excluded, it lives on `/dokumenti`), dashboard badge via `GET /inbox/count`. No client-side raw-byte preview; files are proxied through the expense-documents content endpoint.
- `apps/web/app/(dashboard)/inbox/[id]/page.tsx` — review/booking detail screen.
- `apps/web/app/(dashboard)/dokumenti/page.tsx` — permanent archive screen (MC #105687).
- **Sidebar nav** (`apps/web/components/sidebar.tsx`): two entries under the `expensesGroup` section — `{ key: 'inbox', href: '/inbox', icon: Inbox }` and `{ key: 'dokumenti', href: '/dokumenti', icon: Archive }` — both placed above `expenses` and `purchases` in the group.

## 6. i18n

Sidebar labels are localized via `apps/web/messages/{bs,en,hr,sr-Cyrl,sr-Latn}.json`, `navigation` namespace:
- `"inbox": "Ulaz dokumenata"`
- `"dokumenti": "Arhiva dokumenata"`

**Gap found during this review:** the inbox/dokumenti *page bodies* (`inbox/page.tsx`, `inbox/[id]/page.tsx`, `dokumenti/page.tsx`) do **not** call `useTranslations`/`t(...)` — grep for both found zero matches. All in-page copy (labels, buttons, empty states) is hardcoded Bosnian/Croatian JSX, not routed through next-intl. Only the sidebar navigation label is translated. This is a real gap, not a design choice documented anywhere in the code — flagging it here rather than in the "OCR hooks" section since it's a currently-live inconsistency, not deferred work.

## 7. Phase 2 — OCR hooks (deferred, not built)

Both migration headers and the `Tables.kt` block comment explicitly scope OCR to Phase 2, deferred:

> "Phase 2 (OCR via Azure Document Intelligence): extracted_* columns are nullable and reserved for Phase 2 population. Phase 1 build leaves them NULL."

What exists today as the OCR integration point:
- Six nullable columns on `inbox_items`: `extracted_amount`, `extracted_currency`, `extracted_date`, `extracted_vendor`, `extracted_vat`, `ocr_confidence` (0–1 range, checked).
- No service, route, or background job populates them — confirmed by reading `InboxRoutes.kt` and `InboxService.kt` end to end; no reference to Azure Document Intelligence, OCR, or any of the six `extracted_*`/`ocr_confidence` fields appears outside the schema/comments.
- No Phase 2 MC task exists yet for the OCR build itself (only the Phase 1 capture queue, MC #104515, and the archive extension, MC #105687, have shipped).

**Implication for a future Phase 2 build:** the schema already has the landing spot for OCR output; the work is a new async job (upload → queue → Azure Document Intelligence call → populate `extracted_*`/`ocr_confidence` → surface a "confirm extracted values" step in the `/inbox/{id}` review screen before booking). No API contract for that job exists yet.

## 8. Verification method

All facts on this page were read directly from `azdo/main` (Bilko repo, `~/business/ALAI-Holding-AS/products/Bilko`) at commit `88769328` (2026-08-08), not from prior planning docs or memory:
- `git log --all --grep`, `git diff main...feat/document-inbox-104515 --stat`, `git merge-base --is-ancestor` to confirm the feature branch's content reached `main` (Azure DevOps squash-merges, so individual feature-branch commits are not ancestors of `main` even though the content is — checked via `git ls-tree -r azdo/main` file presence, not commit ancestry alone).
- Full reads of `V106__document_inbox.sql`, `V122__inbox_archive_documents.sql`, `InboxRoutes.kt` (630 lines), relevant sections of `Tables.kt`, `Routing.kt`, `DI.kt`, `sidebar.tsx`, `bs.json`, `V67__rbac_permissions_catalog.sql`, `RbacHelper.kt`, and the first ~60 lines of `inbox/page.tsx`.
- The original MC #104515 gap audit (status: done, 2026-06-29) is the origin of this task but is now stale — it predates all three merges above and should not be treated as current state.

## 9. Cross-references

- MC #104515 — Fiken-gap audit that identified doc inbox as missing + Phase 1 build
- MC #104519 — Proveo route-layer test coverage for Phase 1
- MC #105687 — Permanent document archive / `archived` status (V122)
- MC #106195 — Inbox/`ulazni-racuni` UX redesign
- MC #106852 (G1-02) — `UploadSecurityGate` shared upload security choke point
- Related BookStack page: "Bilko Operational Runbook — Azure Container Apps" (same book)

<!-- ALAI-MC:900178:BEFORE -->
MC #900178 (2026-08-24): PROD BUG — upload u Ulaz dokumenata pada za sve korisnike: apiFetch šalje Content-Type: application/json s FormData body, server multipart ruta odbija. Fix: JSON header default samo za string/prazan body. RCA: evidence/incident-inbox-upload-20260823/RCA.md; forged prompt 900178.md.