Skip to main content

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/rejectedarchived 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.