Skip to main content

Bilko HR — Inbound e-Invoice Reject + Monthly Rejection eIzvještavanje (MC #106149 / #106146)

MC #106149 (reject action, M) + #106146 (monthly eIzvještavanje report, H, RED-ZONE HR tax/compliance). Coupled/parent #106145. Status as of 2026-07-21: built and tested in an isolated worktree, NOT merged or pushed — no PR opened yet. This page documents the built state for handoff / next-session continuation.

What was built

  • Reject action on received_einvoice (inbound/AP e-invoice): transitions processing_status and records a Bilko-internal rejection reason code, reject timestamp, and eIzvještavanje transmission tracking fields.
  • RejectionReportService: builds the monthly batch of rejected inbound e-invoices Bilko must report to Porezna uprava under čl.52 (rok: 20th of the month, for the prior calendar month — domain-gate CONFIRMED, web-verified via 6 convergent sources, distinct from the separate NN 151/2025 PDV-filing deadline change).

IMPORTANT — N/U/O is Bilko's own taxonomy, NOT Porezna's

The domain gate (agent bilko-porez-fiskalizacija-hr) caught and corrected a first-draft modeling error before this shipped:

  • Porezna's actual N/U/O are three different eIzvještavanje REPORT TYPES, not three rejection sub-reasons:
    • N = Naplata — issuer reports collection/payment of an issued e-invoice (unrelated to rejection)
    • U = Usluge/Isporuke — paper-fallback reporting when an e-invoice couldn't be issued (unrelated to rejection)
    • O = Odbijanje — recipient reports a REJECTED inbound e-invoice under čl.52. This is the only one relevant to Bilko's use case.
  • Every row Bilko reports to Porezna is type O — there is no per-rejection N/U/O choice on the Porezna side.
  • Fix applied: received_einvoice.reason_code is documented (Kotlin KDoc + V140 SQL comment) as Bilko's own internal rejection-reason taxonomy for the UI/audit trail (satisfying čl.52 st.1's "obrazloženo"/justified requirement), reusing the N/U/O letters only for DB/product convenience — not a claim of matching Porezna's N/U/O.
  • RejectionReportService.RECORD_TYPE is hardcoded to "O" and is never derived from received_einvoice.reason_code. Tested explicitly (RejectionReportIntegrationTest test 5: recordType == "O" while bilkoReasonCode == "U", asserted as two independently-preserved fields).

Full domain-gate detail: ~/system/evidence/106149/domain-gate-verdict.md

Schema

MigrationContents
V140__received_einvoice_reject_reason.sqlAdds to received_einvoice: reason_code CHAR(1) CHECK IN ('N','U','O') (Bilko-internal taxonomy, see above), rejected_at, report_transmission_status CHECK IN ('pending','sent'), report_transmitted_at, report_period. Does not edit V139.
V141__rejection_report_rls_bypass.sqlTwo bilko_auth SECURITY DEFINER functions — find_rejected_einvoices_for_period, mark_einvoice_report_transmission — plus GRANT SELECT, UPDATE ON TABLE public.received_einvoice TO bilko_admin. Same ADR-017 Pattern B shape as V139's own find_org_by_registration_number.

Lessons — RLS/SECURITY DEFINER pattern has now recurred 6x (V33/V39/V100/V111/V139/V141)

Two real bugs were caught live by the Testcontainers integration test (real Postgres + full V1..V141 Flyway chain, no SchemaUtils/mocks) during this build — not assumed, not pre-empted from memory:

  1. Cross-org RLS blind spot. RejectionReportService's monthly batch query initially used a raw Exposed transaction{} against received_einvoice (FORCE ROW LEVEL SECURITY, V139) with no app.current_org_id set. This silently returned ZERO rows for every org, every time — not an error, because RLS here is fail-closed-permissive: the query "succeeds" with an empty result set. Fixed via V141 SECURITY DEFINER functions.
  2. SECURITY DEFINER without table GRANT. After adding the SECURITY DEFINER functions, they still failed with "permission denied for table received_einvoice" — bilko_admin has BYPASSRLS (V30_1/V32) but is not superuser and never received an explicit GRANT on this specific table (V139 only granted bilko_app). Fixed by adding the GRANT in V141, following the exact precedent already set by V33/V39/V100/V111 for other tables bilko_admin needed to read via SECURITY DEFINER functions.

Callout for future FORCE-RLS table + batch/admin-query work: any new table with FORCE ROW LEVEL SECURITY that needs a cross-org admin/batch read path will hit both of these unless it (a) goes through a SECURITY DEFINER function from the start, and (b) that function's owning role has an explicit table GRANT. This is now a 6-occurrence pattern — worth turning into a migration checklist item or lint rule rather than re-discovering it per-feature.

What's explicitly NOT done

  • Actual HTTP submission to Porezna is not implemented. transmitToPorezna() is a clearly-marked TODO stub that always returns false (even with SVERACUN_HR_LIVE=true), with a loud log.warn explaining why. submitMonthlyReport() therefore never falsely claims "sent".
  • Reason: payload/schema for the real eIzvještavanje submission is not confirmed at field level. https://porezna-uprava.gov.hr/fiskalizacija/api/dokumenti/157 returns HTTP 200 but serves a client-rendered SPA shell ("Naslovna"), not the raw document body — the literal field-level schema needs either human/browser (JS-rendering) access to that page, or the FINA/Porezna eIzvještavanje XSD.
  • No cron wiring yet. The POST /internal/compliance/rejection-report endpoint is not wired to an active ACA scheduled trigger in this task — same "safe to deploy inert" posture as the existing send-reminders sibling endpoint. FlowForge/John to wire per the KDoc go-live note.
  • No UI. API-only, M-tier task, out of scope per the original brief.

Verification performed

  • ./gradlew compileKotlin compileTestKotlin — clean.
  • RejectionReportIntegrationTest (new, Testcontainers postgres:16-alpine, real V1..V141 Flyway chain, no mocks): 8/8 passed. Covers reject persistence + status transition, no-op same-reason re-reject vs tracked different-reason correction, org isolation both directions, rejecting an already-accepted row throws, report payload recordType=O with correct bilkoReasonCode, non-rejected rows excluded, transmission state null→pending (never falsely 'sent'), report_period stamping, and idempotency (a row already 'sent' for a period is not re-transmitted).
  • Regression: pre-existing ReceivedEInvoiceIntegrationTest (MC #106162) re-run after V140/V141/Tables.kt changes — still 0 failures.
  • Full suite: ./gradlew test — 1808 tests, 25 failed, all 25 isolated to 3 unrelated pre-existing classes (root cause: SVERACUN_SENDER_VAT not configured, a local-env config gap in outbound sveRacun e-invoicing, grep-verified unrelated to ReceivedEInvoice*/RejectionReport*/ComplianceCron*).

Branch / merge status

  • Branch: feat/task-106149-reject-report, commit faf3aa78.
  • Based on: feat/task-106162-inbound-einvoice-persist, commit 06812347 (V139 received_einvoice table + persist).
  • Neither branch is merged or pushed to azdo main. No PR opened yet.
  • Built in isolated worktree .claude/worktrees/codecraft-106149 — the main Bilko working tree was left untouched (it had unrelated in-flight work on feat/task-106147).

Next steps

  1. Human/browser session to pull field-level schema from porezna-uprava.gov.hr/fiskalizacija/api/dokumenti/157 (or FINA XSD) before implementing real transmitToPorezna().
  2. Proveo end-to-end verification (reject → report, real evidence).
  3. Open PR: feat/task-106162-inbound-einvoice-persist → main, then feat/task-106149-reject-report → main (or squash/rebase as one PR chain).
  4. FlowForge: wire cron trigger for POST /internal/compliance/rejection-report.
  5. mc.js ready for #106149/#106146 after Proveo pass.

Source evidence

  • ~/system/evidence/106149/build-verdict.md — full build verdict (CodeCraft, agent id codecraft-106149, PASS)
  • ~/system/evidence/106149/domain-gate-verdict.md — domain/legal correction (agent bilko-porez-fiskalizacija-hr, CORRECTION NEEDED on Q1, applied)