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) 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.