Skip to main content

Bilko Modul B-1 — GL Foundation Build (2026-06-13)

TL;DR

Bilko Module B-1 GL Foundation is built on branch feature/b1-gl-foundation (commit 687f1d0b, PR #369). The GL subsystem is gated behind two feature flags — BILKO_ACCOUNTING_GL and GL_AUTO_POST — both seeded OFF globally. Module A (invoicing) is completely untouched when either flag is off. Proveo independent validation returned PASS (mesh-thr-proveo-103535, 2026-06-13). PR #369 is not yet merged or deployed; it awaits operator sign-off and the [VERIFY-NN] legal-review gate before production auto-posting can be enabled.

Architecture

Database — 5 tables, Flyway migration V84

TablePurposeKey constraints
bilko_flags Platform kill-switch flags (no Unleash dependency). org_id IS NULL = global default; non-null = per-org override. Surrogate UUID PK + expression unique index uq_bilko_flags(flag_name, COALESCE(org_id, '00...00')) — required because Postgres 16 does not allow COALESCE in inline PRIMARY KEY/UNIQUE syntax.
journal_entries GL header (one row per business event). Append-only per Article 11.3 ZoR. UNIQUE(org_id, source_type, source_document_id) — idempotency invariant. Status enum: DRAFT / POSTED / REVERSED. RLS via V75 NULLIF fail-closed canonical pattern.
journal_postings GL legs (debit/credit rows). Multiple rows per entry. amount > 0, side IN ('DEBIT','CREDIT'). Immutability trigger fires if parent entry is POSTED/REVERSED.
posting_rules Config-driven Vlado JSONB posting templates. Engine reads rules from here; never hardcodes account numbers. Seeded with R-1 (taxable domestic) and R-3a (EU_41 exempt) on migration. Additional rules added without code changes.
account_mapping Org-configurable logical-role-to-account-code mapping. Global HR defaults seeded (9 entries). Orgs can override per jurisdiction. Expression unique index uq_am_org_role(COALESCE(org_id,...), jurisdiction, logical_role) — same PG16 fix pattern as bilko_flags.

Postgres Triggers — 3 enforcement points

TriggerFunctionWhat it enforcesError code
trg_je_balance_check fn_je_balance_check Sum(DEBIT) == Sum(CREDIT) at the moment status transitions to POSTED. DRAFT entries are allowed to be unbalanced during assembly. P0002 (balance violation) / P0003 (zero postings)
trg_je_immutability fn_je_immutability BEFORE UPDATE OR DELETE on journal_entries where OLD.status IN ('POSTED','REVERSED'). DRAFT rows remain mutable. P0001
trg_jp_immutability fn_jp_immutability BEFORE UPDATE OR DELETE on journal_postings — checks parent entry status; fires if parent is POSTED/REVERSED. P0004

PostingRuleEngine (Kotlin)

Located at apps/api/src/main/kotlin/no/alai/bilko/gl/PostingRuleEngine.kt. The engine is config-driven: it reads JSONB posting templates from the posting_rules table and resolves amounts from the incoming GlDocumentData struct. It never computes tax; all net/vat/gross values must originate from the Module A invoice document.

Key behaviours:

  • Gross check — net + vat must equal gross; mismatch returns REJECTED status (no crash).
  • Rule lookup — matched by (event_type, jurisdiction, vat_exemption_code). Most-specific match wins (rule with explicit vat_exemption_code scores higher than wildcard null match).
  • split_by vat_rate — for multi-rate invoices (Event 2), one CREDIT posting is emitted per distinct VAT rate line, each carrying vat_rate for the B-2 VAT return.
  • No rule found — returns ZA_KONTIRANJE status (no crash, Module A unaffected, accountant must manually post).
  • All drafts — status=DRAFT, requiresAccountantConfirmation=true always set on output.

GlBridge — A to B integration point

Located at apps/api/src/main/kotlin/no/alai/bilko/gl/GlBridge.kt. Called from InvoiceService.sendInvoice() inside the existing orgTransaction after the invoice transitions to SENT status.

  • If BILKO_ACCOUNTING_GL=false: early return, engine is never called (MockK verify exactly=0 confirmed by Proveo).
  • If GL_AUTO_POST=false: early return.
  • Both ON: engine called, result persisted as DRAFT via GlRepository.persistDraft().
  • Idempotent: if a journal entry already exists for the same (org, source_type, source_document_id), persistDraft returns null (no duplicate, no exception).
  • Non-throwing: all GL errors are caught and logged; Module A (invoice flow) is never interrupted.

Posting Rules — Vlado's 6 Events

Domain contract authored by Vlado Brkanić (certified accountant, design-partner). Scope: outgoing/sales documents only, Croatian jurisdiction (HR). All amounts in EUR. Rates: 25% / 13% / 5% [VERIFY-NN čl.38].

EventDescriptionDebit legsCredit legs
1 — SALES_INVOICE_ISSUED (standard) Taxable domestic invoice, VAT 25%, buyer Croatia. accounting_date = delivery/issue date. 1200 Kupci HR (gross) 7600 Prihodi HR (net), 2400 PDV obveza (vat)
2 — Multi-rate (13%+5%) Same as Event 1 but VAT split across rates. Engine emits one CREDIT posting per rate, each carrying vat_rate. 1200 (gross) 7600 (net), 2400 @13% (vat slice), 2400 @5% (vat slice)
3a — EU exempt (čl.41) B2B EU supply, zero VAT, report_target=ZP. Precondition: valid VIES VAT-ID. 1201 Kupci EU (gross) 7610 Prihodi EU (net) — no 2400
3b — Export exempt (čl.45) Third-country export, zero VAT, not reported in ZP. Proof: customs export declaration. 1201 (gross) 7610 (net) — no 2400
3c — Exempt without deduction right (čl.39/40) Zero VAT. vat_exemption_code preserved for B-2 pro-rata coefficient calculation. 1200/1201 (gross) 7600/7610 (net) — no 2400
4 — PAYMENT_RECEIVED Cash inflow. Does not touch revenue or VAT accounts. Partial payment leaves receivable open. 1000 Žiro-račun (payment.amount); 1020 Blagajna for cash 1200 Kupci (closes receivable, closes_document_id set)
5 — CREDIT_NOTE Reversal of Event 1. Reversing entry pattern — all legs inverted. source_type=CREDIT_NOTE, reverses_document_id set. No deletion of original. 7600 Prihodi (net), 2400 PDV (vat) 1200 Kupci (gross)
6 — Advance (3 steps) 6a Advance received: D 1000 / C 2310 (net) + C 2410 (VAT on advance). VAT liability arises on receipt [VERIFY-NN čl.30/5].
6b Final invoice on delivery: D 1200 / C 7600 + C 2400. Revenue recognised once.
6c Netting: D 2310 + D 2410 / C 1200. Advance VAT reversed. Result: 2310/2410/1200 net to zero; no VAT doubling.
See 6a/6b/6c See 6a/6b/6c

JSONB Rule Format — R-1 and R-3a examples

R-1: Taxable domestic invoice (vat_exemption_code is null):

{
  "event_type": "SALES_INVOICE_ISSUED",
  "jurisdiction": "HR",
  "match": { "vat_exemption_code": null },
  "postings": [
    { "account": "1200", "side": "DEBIT",  "amount_source": "invoice.gross", "analytic": "partner:{invoice.partner_oib}" },
    { "account": "7600", "side": "CREDIT", "amount_source": "invoice.net" },
    { "account": "2400", "side": "CREDIT", "amount_source": "invoice.vat_by_rate", "split_by": "vat_rate", "carry": ["vat_rate"] }
  ],
  "balance_assert": "sum(DEBIT) == sum(CREDIT)",
  "status_on_create": "DRAFT",
  "requires_accountant_confirmation": true
}

R-3a: EU-exempt supply (vat_exemption_code = "EU_41"):

{
  "event_type": "SALES_INVOICE_ISSUED",
  "jurisdiction": "HR",
  "match": { "vat_exemption_code": "EU_41" },
  "report_target": "ZP",
  "postings": [
    { "account": "1201", "side": "DEBIT",  "amount_source": "invoice.gross", "analytic": "partner:{invoice.partner_vat_id}" },
    { "account": "7610", "side": "CREDIT", "amount_source": "invoice.net" }
  ],
  "balance_assert": "sum(DEBIT) == sum(CREDIT)",
  "status_on_create": "DRAFT",
  "requires_accountant_confirmation": true,
  "preconditions": ["partner_vat_id_valid_vies"]
}

How to Enable the Module (Operator Runbook)

The two flags

FlagDefaultEffect when ON
BILKO_ACCOUNTING_GL false (global) Enables the GL subsystem for the org. GlBridge checks this first. Without it, the engine is never called. Can be set per-org via bilko_flags (org_id non-null).
GL_AUTO_POST false (global) When both flags are ON, GlBridge calls the engine and persists a DRAFT journal entry on every invoice send. DRAFT entries appear in the accountant's queue for review and confirmation. The system never auto-transitions a DRAFT to POSTED.

What happens when both flags are ON

  1. Invoice transitions to SENT in Module A (InvoiceService).
  2. GlBridge.onInvoiceIssued() is called inside the existing orgTransaction.
  3. PostingRuleEngine evaluates the invoice against JSONB rules in posting_rules.
  4. A DRAFT journal entry + postings are persisted. Status = DRAFT, requiresAccountantConfirmation = true.
  5. Accountant reviews the DRAFT in the (future) accounting module UI and transitions to POSTED manually.
  6. If an entry already exists for this invoice (idempotency key), step 4 is a no-op.

This gate must pass before auto-post can go live in production. Vlado's domain contract contains 10 [VERIFY-NN] annotations referencing Croatian tax law articles (ZoPDV, ZoR). The exact Narodne Novine references must be verified against porezna-uprava.gov.hr / narodne-novine.nn.hr before any POSTED auto-entries are generated for real clients. This is a platform-admin review gate — do not skip.

Validation Evidence

Proveo verdict: PASS — Angie Jones, 2026-06-13. Mesh thread: mesh-thr-proveo-103535-20260613T044650Z. Validation report: /tmp/evidence-103535/VALIDATION-REPORT-v2.md.

Two validation rounds were required:

  • v1 (PARTIAL) — Commit 464c3d14: V84 migration blocked with SQLState 42601. Postgres 16 does not allow COALESCE expressions inside inline PRIMARY KEY / UNIQUE table constraint syntax. Affected 4 locations across bilko_flags and account_mapping.
  • v2 (PASS) — Commit 687f1d0b (fix): Replaced inline COALESCE constraints with surrogate UUID PKs + separate expression unique indexes. All 4 locations corrected.

DB-invariant proofs (12 sub-tests on live Postgres 16)

InvariantProbeExpectedSQLStateResult
Balance trigger (reject) POST entry with D=1000 / C=800, transition to POSTED Exception: "balance violation" P0002 PASS
Balance trigger (allow) POST entry with D=1250 / C=1250, transition to POSTED No exception PASS
Idempotency Insert duplicate (org_id, source_type, source_document_id) Unique violation 23505 PASS
Entry immutability (UPDATE) UPDATE POSTED journal_entry Immutability violation P0001 PASS
Entry immutability (DELETE) DELETE POSTED journal_entry Immutability violation P0001 PASS
Posting immutability (UPDATE) UPDATE posting row of POSTED entry Immutability violation P0004 PASS
Posting immutability (DELETE) DELETE posting row of POSTED entry Immutability violation P0004 PASS
DRAFT mutability UPDATE on DRAFT journal_entry No exception, persisted PASS
+4 schema/flag proofs (tables present, indexes present, triggers present, flag seeds confirmed)

Test counts

  • 16 unit tests: PostingRuleEngineTest (10/10) + GlBridgeTest (6/6) — MockK, no DB.
  • 12 DB-invariant tests: GlInvariantDbTest — full Flyway V1-V84 on postgres:16-alpine Testcontainer.
  • SelfPostingConstraintTest (2/2) — migration regression guard.
  • Pre-existing 4 failures in CountryPlugin XML tests — same on main, unrelated to B-1.

Flag safety (GlBridgeTest 6/6)

  • BILKO_ACCOUNTING_GL=false: engine never called (MockK verify exactly=0).
  • GL_AUTO_POST=false: engine never called.
  • Both ON + DRAFT: engine called x1, persistDraft called x1.
  • Both ON + ZA_KONTIRANJE result: no persist, no crash.
  • Engine exception: swallowed, Module A unaffected.

What Is NOT Done Yet (Deferred to next B-1 slice)

  • Frontend kontni-plan UI — accountant-facing account plan management screen.
  • Glavna knjiga / bruto bilanca API endpoints — general ledger summary and trial balance REST endpoints.
  • Accountant DRAFT → POSTED screen — UI for accountant to review and confirm draft journal entries.
  • Remaining posting rule seeds — Events 3b (EXPORT_45), 3c (EXEMPT_39/40), 4 (PAYMENT_RECEIVED), 5 (CREDIT_NOTE), 6a/6b/6c (advance/advance-settlement) are domain-specified but not yet seeded as active rules in posting_rules table.
  • Live end-to-end test — full invoice send with both flags ON, confirming a real DRAFT entry lands in the DB. Recommended for B-2 gate (Proveo open item).
  • [VERIFY-NN] legal review — porezna-uprava.gov.hr article verification gate before production auto-posting is enabled.