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

## TL;DR

 Bilko Modul B-1 GL Foundation je **KOMPLETAN**. Backend (commit 6efb16f9) + frontend (commiti 69c87cf3 + b1f6401a) izgradeni. GL subsystem je iza `BILKO_ACCOUNTING_GL` + `GL_AUTO_POST` flagova — obje default **OFF** globalno. Modul A (fakturisanje) potpuno netaknut. Proveo UAT **PASS 20/20** (MC #103549), Vlado domain acceptance **PRIHVACENO 19/19**, Petter (lead) sign-off: **merge GO**. PR #369 ceka CEO merge odluku — dormantan, flag-gated, nema regresije.

## B-1 ZAVRSEN (stanje 2026-06-13)

 **Bilko Modul B-1 — GL Foundation je KOMPLETAN.** Backend + frontend izgradjeni, Proveo UAT PASS (20/20), Vlado domain acceptance PRIHVACENO (19/19), Petter (lead) sign-off: merge GO. PR #369 je spreman za CEO merge odluku — flag-gated (BILKO\_ACCOUNTING\_GL default OFF), Module A potpuno netaknut, nema regresije.

### Sta modul radi (iza BILKO\_ACCOUNTING\_GL flag, default OFF)

#### Backend (commit 6efb16f9)

- GL engine: tabele `journal_entries`, `journal_postings`, `posting_rules`, `account_mapping`, `bilko_flags`
- PG trigeri: balance-invariant (P0001), immutability (P0002), idempotency (P0004) — okidaju uzivo na PG16
- 9 REST endpointa na `/api/v1/accounting/*`
- Flyway V85 — permissions seed (`accounting:view`, `accounting:post`, `accounting:manage`)
- PAYMENT/CREDIT\_NOTE bridge (unit-tested; GL\_AUTO\_POST zasebni flag, default OFF)
- 68 GL unit + 10 HTTP integration testova — sve zeleno

#### Frontend (commiti 69c87cf3 + b1f6401a)

- 5 stranica pod `app/(dashboard)/accounting/`: 
    - **kontni-plan** — tabela Konto | Naziv | Uloga, HR account names (RRiF konvencija)
    - **temeljnice** — glavna knjiga, paginirani pregled DRAFT/POSTED/REVERSED sa filterima
    - **temeljnica/\[id\]** — detalj: postings Duguje/Potrazuje, akcije Potvrdi (DRAFT→POSTED) i Storno
    - **nova-temeljnica** — rucni unos s live balance-check (ΣD=ΣC prikazano uzivo)
    - **bruto-bilanca** — per-account totalDebit/totalCredit/saldo, grand totals, asOf filter; ΣD=ΣC indikator
- Sidebar "Knjigovodstvo" nav sekcija (gating: accountant/admin/owner rola)
- 404-graceful gating — kada je flag OFF, stranice prikazuju "Ovaj modul nije dostupan" (ne crashaju)
- 5 lokalizacijskih lokala
- Playwright spec: `apps/e2e/tests/accounting-b1.spec.ts` (commitan, spreman za browser run kada je demo SSL dostupan)

#### Tok racunovodje

1. Otvori **Kontni plan** — pregled konta (logicalRole / accountCode / jurisdikcija, HR RRiF)
2. Faktura/placanje kreira DRAFT temeljnicu automatski (kada je GL\_AUTO\_POST ON) ili rucni unos
3. **Potvrdi** DRAFT → POSTED (trajna, nepromjenjiva knjizba)
4. POSTED entry se pojavljuje u **Glavnoj knjizi** (temeljnice pregled)
5. **Bruto bilanca** pokazuje ΣD=ΣC — system-level invariant verificiran trigerom i API-jem
6. Storno = nova reversing POSTED entry (append-only, original nikad ne brises; cl. 11.3 ZoR)

### Validacija

<table id="bkmrk-slojrezultatdetalji-"> <thead> <tr><th>Sloj</th><th>Rezultat</th><th>Detalji</th></tr> </thead> <tbody> <tr> <td>Proveo UAT MC #103549</td> <td>**PASS 20/20**</td> <td>Puni lifecycle (DRAFT→POSTED→storno) + gate testovi (flag OFF → 404) + permission negatives (viewer → 403, unbalanced → 400). Bruto bilanca ΣD=ΣC rucno verificirano. Mesh: mesh-thr-proveo-103535-20260613T044650Z. Layer: integration + fe-build (browser odgodjeno — demo SSL nedostupan; spec commitan).</td> </tr> <tr> <td>Vlado domain acceptance</td> <td>**PRIHVACENO 19/19**</td> <td>Ovlasteni racunovoda. Posting-rule templates (6 dogadjaja, JSONB kontrakt), kontni plan HR, PDV razlaganje po stopi, reversing-entry semantika, append-only nepromjenjivost.</td> </tr> <tr> <td>Petter (lead) sign-off</td> <td>**B-1 COMPLETE, merge GO**</td> <td>Commit 6efb16f9 backend + 69c87cf3/b1f6401a frontend. 78 testova zeleno. Scope eksplicitno zatvoreno per B-1 DoD.</td> </tr> </tbody></table>

### Iskrene ogranicenja / odgodeno na B-2

- **Live invoice → auto-DRAFT:** bridge unit-tested; e2e nije provjereno (GL\_AUTO\_POST je OFF zasebni flag; aktivira se u B-2 kada aktiviramo pilot org)
- **Browser-layer E2E:** odgodjeno (demo SSL nedostupan za Playwright live run); spec commitan u `apps/e2e/tests/accounting-b1.spec.ts`
- **Sekvencijalno numerisanje temeljnica:** B-2
- **Predujam (avans) bridge kompletno:** B-2 (6c knjizenje — netiranje avansa, djelomicni avans)

### Production-activation gates (prije pravog racunovodje)

1. Operator ukljuci `BILKO_ACCOUNTING_GL = true` za imenovanog pilot org (`GL_AUTO_POST` ostaje OFF do gate 2)
2. **\[VERIFY-NN\] OBAVEZNO:** pravna provjera na narodne-novine.nn.hr / porezna-uprava.gov.hr za 10 VERIFY-NN stavki (PDV stope/clanci) — nije opcionalno
3. Imenovan racunovoda design-partner (board U2) live walkthrough sign-off

### Status PR #369

 Merge preporuka: **GO** — dormantan, flag-gated, Module A netaknut, nema regresije. Ceka CEO merge odluku.

### Veze

- Spec/board page: [page 3115](https://docs.alai.no/books/bilko/page/3115) (B-1 board presuda)
- PR #369 — `feature/b1-gl-foundation`
- MC #103531 (parent) | #103547 (backend Proveo) | #103548 (frontend Vizu) | #103549 (UAT) | #103550 (docs)

## Architecture

### Database — 5 tables, Flyway migration V84

<table id="bkmrk-tablepurposekey-cons"> <thead> <tr><th>Table</th><th>Purpose</th><th>Key constraints</th></tr> </thead> <tbody> <tr> <td>`bilko_flags`</td> <td>Platform kill-switch flags (no Unleash dependency). `org_id IS NULL` = global default; non-null = per-org override.</td> <td>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.</td> </tr> <tr> <td>`journal_entries`</td> <td>GL header (one row per business event). Append-only per Article 11.3 ZoR.</td> <td>UNIQUE(org\_id, source\_type, source\_document\_id) — idempotency invariant. Status enum: DRAFT / POSTED / REVERSED. RLS via V75 NULLIF fail-closed canonical pattern.</td> </tr> <tr> <td>`journal_postings`</td> <td>GL legs (debit/credit rows). Multiple rows per entry.</td> <td>`amount > 0`, side IN ('DEBIT','CREDIT'). Immutability trigger fires if parent entry is POSTED/REVERSED.</td> </tr> <tr> <td>`posting_rules`</td> <td>Config-driven Vlado JSONB posting templates. Engine reads rules from here; never hardcodes account numbers.</td> <td>Seeded with R-1 (taxable domestic) and R-3a (EU\_41 exempt) on migration. Additional rules added without code changes.</td> </tr> <tr> <td>`account_mapping`</td> <td>Org-configurable logical-role-to-account-code mapping. Global HR defaults seeded (9 entries). Orgs can override per jurisdiction.</td> <td>Expression unique index `uq_am_org_role(COALESCE(org_id,...), jurisdiction, logical_role)` — same PG16 fix pattern as bilko\_flags.</td> </tr> </tbody></table>

### Postgres Triggers — 3 enforcement points

<table id="bkmrk-triggerfunctionwhat-"> <thead> <tr><th>Trigger</th><th>Function</th><th>What it enforces</th><th>Error code</th></tr> </thead> <tbody> <tr> <td>`trg_je_balance_check`</td> <td>`fn_je_balance_check`</td> <td>Sum(DEBIT) == Sum(CREDIT) at the moment `status` transitions to POSTED. DRAFT entries are allowed to be unbalanced during assembly.</td> <td>P0002 (balance violation) / P0003 (zero postings)</td> </tr> <tr> <td>`trg_je_immutability`</td> <td>`fn_je_immutability`</td> <td>BEFORE UPDATE OR DELETE on `journal_entries` where OLD.status IN ('POSTED','REVERSED'). DRAFT rows remain mutable.</td> <td>P0001</td> </tr> <tr> <td>`trg_jp_immutability`</td> <td>`fn_jp_immutability`</td> <td>BEFORE UPDATE OR DELETE on `journal_postings` — checks parent entry status; fires if parent is POSTED/REVERSED.</td> <td>P0004</td> </tr> </tbody></table>

### 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\].

<table id="bkmrk-eventdescriptiondebi"> <thead> <tr><th>Event</th><th>Description</th><th>Debit legs</th><th>Credit legs</th></tr> </thead> <tbody> <tr> <td>1 — SALES\_INVOICE\_ISSUED (standard)</td> <td>Taxable domestic invoice, VAT 25%, buyer Croatia. accounting\_date = delivery/issue date.</td> <td>1200 Kupci HR (gross)</td> <td>7600 Prihodi HR (net), 2400 PDV obveza (vat)</td> </tr> <tr> <td>2 — Multi-rate (13%+5%)</td> <td>Same as Event 1 but VAT split across rates. Engine emits one CREDIT posting per rate, each carrying vat\_rate.</td> <td>1200 (gross)</td> <td>7600 (net), 2400 @13% (vat slice), 2400 @5% (vat slice)</td> </tr> <tr> <td>3a — EU exempt (čl.41)</td> <td>B2B EU supply, zero VAT, report\_target=ZP. Precondition: valid VIES VAT-ID.</td> <td>1201 Kupci EU (gross)</td> <td>7610 Prihodi EU (net) — no 2400</td> </tr> <tr> <td>3b — Export exempt (čl.45)</td> <td>Third-country export, zero VAT, not reported in ZP. Proof: customs export declaration.</td> <td>1201 (gross)</td> <td>7610 (net) — no 2400</td> </tr> <tr> <td>3c — Exempt without deduction right (čl.39/40)</td> <td>Zero VAT. vat\_exemption\_code preserved for B-2 pro-rata coefficient calculation.</td> <td>1200/1201 (gross)</td> <td>7600/7610 (net) — no 2400</td> </tr> <tr> <td>4 — PAYMENT\_RECEIVED</td> <td>Cash inflow. Does not touch revenue or VAT accounts. Partial payment leaves receivable open.</td> <td>1000 Žiro-račun (payment.amount); 1020 Blagajna for cash</td> <td>1200 Kupci (closes receivable, closes\_document\_id set)</td> </tr> <tr> <td>5 — CREDIT\_NOTE</td> <td>Reversal of Event 1. Reversing entry pattern — all legs inverted. source\_type=CREDIT\_NOTE, reverses\_document\_id set. No deletion of original.</td> <td>7600 Prihodi (net), 2400 PDV (vat)</td> <td>1200 Kupci (gross)</td> </tr> <tr> <td>6 — Advance (3 steps)</td> <td> 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. </td> <td>See 6a/6b/6c</td> <td>See 6a/6b/6c</td> </tr> </tbody></table>

### 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

<table id="bkmrk-flagdefaulteffect-wh"> <thead> <tr><th>Flag</th><th>Default</th><th>Effect when ON</th></tr> </thead> <tbody> <tr> <td>`BILKO_ACCOUNTING_GL`</td> <td>false (global)</td> <td>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).</td> </tr> <tr> <td>`GL_AUTO_POST`</td> <td>false (global)</td> <td>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.</td> </tr> </tbody></table>

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

### \[VERIFY-NN\] legal-review gate

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

<table id="bkmrk-invariantprobeexpect"> <thead> <tr><th>Invariant</th><th>Probe</th><th>Expected</th><th>SQLState</th><th>Result</th></tr> </thead> <tbody> <tr> <td>Balance trigger (reject)</td> <td>POST entry with D=1000 / C=800, transition to POSTED</td> <td>Exception: "balance violation"</td> <td>P0002</td> <td>PASS</td> </tr> <tr> <td>Balance trigger (allow)</td> <td>POST entry with D=1250 / C=1250, transition to POSTED</td> <td>No exception</td> <td>—</td> <td>PASS</td> </tr> <tr> <td>Idempotency</td> <td>Insert duplicate (org\_id, source\_type, source\_document\_id)</td> <td>Unique violation</td> <td>23505</td> <td>PASS</td> </tr> <tr> <td>Entry immutability (UPDATE)</td> <td>UPDATE POSTED journal\_entry</td> <td>Immutability violation</td> <td>P0001</td> <td>PASS</td> </tr> <tr> <td>Entry immutability (DELETE)</td> <td>DELETE POSTED journal\_entry</td> <td>Immutability violation</td> <td>P0001</td> <td>PASS</td> </tr> <tr> <td>Posting immutability (UPDATE)</td> <td>UPDATE posting row of POSTED entry</td> <td>Immutability violation</td> <td>P0004</td> <td>PASS</td> </tr> <tr> <td>Posting immutability (DELETE)</td> <td>DELETE posting row of POSTED entry</td> <td>Immutability violation</td> <td>P0004</td> <td>PASS</td> </tr> <tr> <td>DRAFT mutability</td> <td>UPDATE on DRAFT journal\_entry</td> <td>No exception, persisted</td> <td>—</td> <td>PASS</td> </tr> <tr> <td colspan="5">+4 schema/flag proofs (tables present, indexes present, triggers present, flag seeds confirmed)</td> </tr> </tbody></table>

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

## Cross-links

- Spec and board decision: [BookStack page 3115 — Bilko Modul B (Knjigovodstvo) — Spec + Board presuda](https://docs.alai.no/books/bilko-balkan-accounting-saas/page/bilko-modul-b-knjigovodstvo-spec-board-presuda)
- PR #369: `feature/b1-gl-foundation` (not yet merged)
- MC #103531 (parent build task)
- MC #103535 (Proveo validation task)
- Domain contract: `/tmp/evidence-knjigovodstvo/vlado-posting-rule-templates-B1.md`
- Validation report v2: `/tmp/evidence-103535/VALIDATION-REPORT-v2.md`