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 apendingrow. From there it is either booked (creates anExpenseand 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 theExpenseDocuments/ReceiptService.uploadDocumentstorage pattern - Lifecycle:
status(pending|booked|rejected, widened to addarchivedin 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)(checked0.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:
statuscheck,file_size > 0,storage_backendallowlist,ocr_confidencerange check - Indexes:
(org_id, status),(org_id, created_at DESC),(uploaded_by) - RLS:
ENABLE ROW LEVEL SECURITY+FORCE ROW LEVEL SECURITY, policyorg_isolationscopes every row tocurrent_setting('app.current_org_id')::uuidfor rolebilko_app— same pattern asexpense_documents(V40).
V122 — archive extension (adds the archived outcome, MC #105687):
- Widens the
statusCHECK to includearchived - 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_atwherestatus='archived', partial index ondocument_type, index oncontact_id, GIN index ontags - Design decision (documented in the migration header): extend
inbox_itemsrather than create a newarchived_documentstable, to avoid duplicating storage/RLS/index plumbing.booked_expense_idis 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-hrdomain 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—archivedintentionally excluded, it lives on/dokumenti), dashboard badge viaGET /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 theexpensesGroupsection —{ key: 'inbox', href: '/inbox', icon: Inbox }and{ key: 'dokumenti', href: '/dokumenti', icon: Archive }— both placed aboveexpensesandpurchasesin the group.
6. i18n
"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.ktandInboxService.ktend to end; no reference to Azure Document Intelligence, OCR, or any of the sixextracted_*/ocr_confidencefields 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-ancestorto confirm the feature branch's content reachedmain(Azure DevOps squash-merges, so individual feature-branch commits are not ancestors ofmaineven though the content is — checked viagit ls-tree -r azdo/mainfile presence, not commit ancestry alone).- Full reads of
V106__document_inbox.sql,V122__inbox_archive_documents.sql,InboxRoutes.kt(630 lines), relevant sections ofTables.kt,Routing.kt,DI.kt,sidebar.tsx,bs.json,V67__rbac_permissions_catalog.sql,RbacHelper.kt, and the first ~60 lines ofinbox/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 /
archivedstatus (V122) - MC #106195 — Inbox/
ulazni-racuniUX redesign - MC #106852 (G1-02) —
UploadSecurityGateshared 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.