-
Adds JournalEntryLine child entity with debit/credit legs per account, completing the pbc-finance GL foundation. Domain: - JournalEntryLine entity: lineNo, accountCode, debit (>=0), credit (>=0), description. FK to parent JournalEntry with CASCADE delete. Unique (journal_entry_id, line_no). - JournalEntry gains @OneToMany lines collection (EAGER fetch, ordered by lineNo). Event subscribers now write balanced double-entry lines: - SalesOrderConfirmed: DR 1100 (AR), CR 4100 (Revenue) - PurchaseOrderConfirmed: DR 1200 (Inventory), CR 2100 (AP) The parent JournalEntry.amount is retained as a denormalized summary; the lines are the source of truth for accounting. The seeded account codes (1100, 1200, 2100, 4100, 5100) match the chart from the previous commit. JournalEntryController.toResponse() now includes the lines array so the SPA can display debit/credit legs inline. Schema: 004-finance-entry-lines.xml adds finance__journal_entry_line with FK, unique (entry, lineNo), non-negative check on dr/cr. Smoke verified on fresh Postgres: - Confirm SO-2026-0001 -> AR entry with 2 lines: DR 1100 $1950, CR 4100 $1950 - Confirm PO-2026-0001 -> AP entry with 2 lines: DR 1200 $2550, CR 2100 $2550 - Both entries balanced (sum DR = sum CR) Caught by smoke: LazyInitializationException on the new lines collection — fixed by switching FetchType from LAZY to EAGER (entries have 2-4 lines max, eager is appropriate). -
First step of pbc-finance GL growth: the chart of accounts. Backend: - Account entity (code, name, accountType: ASSET/LIABILITY/EQUITY/ REVENUE/EXPENSE, description, active) - AccountJpaRepository + AccountService (list, findById, findByCode, create with duplicate-code guard) - AccountController at /api/v1/finance/accounts (GET list, GET by id, POST create). Permission-gated: finance.account.read, .create. - Liquibase 003-finance-accounts.xml: table + unique code index + 6 seeded accounts (1000 Cash, 1100 AR, 1200 Inventory, 2100 AP, 4100 Sales Revenue, 5100 COGS) - finance.yml updated: Account entity + 2 permissions + menu entry SPA: - AccountsPage with sortable list + inline create form - finance.listAccounts + finance.createAccount in typed API client - Sidebar: "Chart of Accounts" above "Journal Entries" in Finance - Route /accounts wired in App.tsx + SpaController + SecurityConfig This is the foundation for the next step (JournalEntryLine child entity with per-account debit/credit legs + balanced-entry validation). The seeded chart covers the 6 accounts the existing event subscribers will reference once the double-entry lines land.
-
The minimal pbc-finance landed in commit bf090c2e only reacted to *ConfirmedEvent. This change wires the rest of the order lifecycle (ship/receive → SETTLED, cancel → REVERSED) so the journal entry reflects what actually happened to the order, not just the moment it was confirmed. JournalEntryStatus (new enum + new column) - POSTED — created from a confirm event (existing behaviour) - SETTLED — promoted by SalesOrderShippedEvent / PurchaseOrderReceivedEvent - REVERSED — promoted by SalesOrderCancelledEvent / PurchaseOrderCancelledEvent - The status field is intentionally a separate axis from JournalEntryType: type tells you "AR or AP", status tells you "where in its lifecycle". distribution/.../pbc-finance/002-finance-status.xml - ALTER TABLE adds `status varchar(16) NOT NULL DEFAULT 'POSTED'`, a CHECK constraint mirroring the enum values, and an index on status for the new filter endpoint. The DEFAULT 'POSTED' covers any existing rows on an upgraded environment without a backfill step. JournalEntryService — four new methods, all idempotent - settleFromSalesShipped(event) → POSTED → SETTLED for AR - settleFromPurchaseReceived(event) → POSTED → SETTLED for AP - reverseFromSalesCancelled(event) → POSTED → REVERSED for AR - reverseFromPurchaseCancelled(event) → POSTED → REVERSED for AP Each runs through a private settleByOrderCode/reverseByOrderCode helper that: 1. Looks up the row by order_code (new repo method findFirstByOrderCode). If absent → no-op (e.g. cancel from DRAFT means no *ConfirmedEvent was ever published, so no journal entry exists; this is the most common cancel path). 2. If the row is already in the destination status → no-op (idempotent under at-least-once delivery, e.g. outbox replay or future Kafka retry). 3. Refuses to overwrite a contradictory terminal status — a SETTLED row cannot be REVERSED, and vice versa. The producer's state machine forbids cancel-from-shipped/received, so reaching here implies an upstream contract violation; logged at WARN and the row is left alone. OrderEventSubscribers — six subscriptions per @PostConstruct - All six order events from api.v1.event.orders.* are subscribed via the typed-class EventBus.subscribe(eventType, listener) overload, the same public API a plug-in would use. Boot log line updated: "pbc-finance subscribed to 6 order events". JournalEntryController — new ?status= filter - GET /api/v1/finance/journal-entries?status=POSTED|SETTLED|REVERSED surfaces the partition. Existing ?orderCode= and ?type= filters unchanged. Read permission still finance.journal.read. 12 new unit tests (213 total, was 201) - JournalEntryServiceTest: settle/reverse for AR + AP, idempotency on duplicate destination status, refusal to overwrite a contradictory terminal status, no-op on missing row, default POSTED on new entries. - OrderEventSubscribersTest: assert all SIX subscriptions registered, one new test that captures all four lifecycle listeners and verifies they forward to the correct service methods. End-to-end smoke (real Postgres, fresh DB) - Booted with the new DDL applied (status column + CHECK + index) on an empty DB. The OrderEventSubscribers @PostConstruct line confirms 6 subscriptions registered before the first HTTP call. - Five lifecycle scenarios driven via REST: PO-FULL: confirm + receive → AP SETTLED amount=50.00 SO-FULL: confirm + ship → AR SETTLED amount= 1.00 SO-REVERSE: confirm + cancel → AR REVERSED amount= 1.00 PO-REVERSE: confirm + cancel → AP REVERSED amount=50.00 SO-DRAFT-CANCEL: cancel only → NO ROW (no confirm event) - finance__journal_entry returns exactly 4 rows (the 5th scenario correctly produces nothing) and ?status filters all return the expected partition (POSTED=0, SETTLED=2, REVERSED=2). What's still NOT in pbc-finance - Still no debit/credit legs, no chart of accounts, no period close, no double-entry invariant. This is the v0.17 minimal seed; the real P5.9 build promotes it into a real GL. - No reaction to "settle then reverse" or "reverse then settle" other than the WARN-and-leave-alone defensive path. A real GL would write a separate compensating journal entry; the minimal PBC just keeps the row immutable once it leaves POSTED.
-
The framework's seventh PBC, and the first one whose ENTIRE purpose is to react to events published by other PBCs. It validates the *consumer* side of the cross-PBC event seam that was wired up in commit 67406e87 (event-driven cross-PBC integration). With pbc-finance in place, the bus now has both producers and consumers in real PBC business logic — not just the wildcard EventAuditLogSubscriber that ships with platform-events. What landed (new module pbc/pbc-finance/, ~480 lines including tests) - JournalEntry entity (finance__journal_entry): id, code (= originating event UUID), type (AR|AP), partner_code, order_code, amount, currency_code, posted_at, ext. Unique index on `code` is the durability anchor for idempotent event delivery; the service ALSO existsByCode-checks before insert to make duplicate-event handling a clean no-op rather than a constraint-violation exception. - JournalEntryJpaRepository with existsByCode + findByOrderCode + findByType (the read-side filters used by the controller). - JournalEntryService.recordSalesConfirmed / recordPurchaseConfirmed take a SalesOrderConfirmedEvent / PurchaseOrderConfirmedEvent and write the corresponding AR/AP row. @Transactional with Propagation.REQUIRED so the listener joins the publisher's TX when the bus delivers synchronously (today) and creates a fresh one if a future async bus delivers from a worker thread. The KDoc explains why REQUIRED is the correct default and why REQUIRES_NEW would be wrong here. - OrderEventSubscribers @Component with @PostConstruct that calls EventBus.subscribe(SalesOrderConfirmedEvent::class.java, ...) and EventBus.subscribe(PurchaseOrderConfirmedEvent::class.java, ...) once at boot. Uses the public typed-class subscribe overload — NOT the platform-internal subscribeToAll wildcard helper. This is the API surface plug-ins will also use. - JournalEntryController: read-only REST under /api/v1/finance/journal-entries with @RequirePermission "finance.journal.read". Filter params: ?orderCode= and ?type=. Deliberately no POST endpoint — entries are derived state. - finance.yml metadata declaring 1 entity, 1 permission, 1 menu. - Liquibase changelog at distribution/.../pbc-finance/001-finance-init.xml + master.xml include + distribution/build.gradle.kts dep. - settings.gradle.kts: registers :pbc:pbc-finance. - 9 new unit tests (6 for JournalEntryService, 3 for OrderEventSubscribers) — including idempotency, dedup-by-event-id contract, listener-forwarding correctness via slot-captured EventListener invocation. Total tests: 192 → 201, 16 → 17 modules. Why this is the right shape - pbc-finance has zero source dependency on pbc-orders-sales, pbc-orders-purchase, pbc-partners, or pbc-catalog. The Gradle build refuses any cross-PBC dependency at configuration time — pbc-finance only declares api/api-v1, platform-persistence, and platform-security. The events and partner/item references it consumes all live in api.v1.event.orders / are stored as opaque string codes. - Subscribers go through EventBus.subscribe(eventType, listener), the public typed-class overload from api.v1.event.EventBus. Plug-ins use exactly this API; this PBC proves the API works end-to-end from a real consumer. - The consumer is idempotent on the producer's event id, so at-least-once delivery (outbox replay, future Kafka retry) cannot create duplicate journal entries. This makes the consumer correct under both the current synchronous bus and any future async / out-of-process bus. - Read-only REST API: derived state should not be writable from the outside. Adjustments and reversals will land later as their own command verbs when the real P5.9 finance build needs them, not as a generic create endpoint. End-to-end smoke verified against real Postgres - Booted on a fresh DB; the OrderEventSubscribers @PostConstruct log line confirms the subscription registered before any HTTP traffic. - Seeded an item, supplier, customer, location (existing PBCs). - Created PO PO-FIN-1 (5000 × 0.04 = 200 USD) → confirmed → GET /api/v1/finance/journal-entries returns ONE row: type=AP partner=SUP-PAPER order=PO-FIN-1 amount=200.0000 USD - Created SO SO-FIN-1 (50 × 0.10 = 5 USD) → confirmed → GET /api/v1/finance/journal-entries now returns TWO rows: type=AR partner=CUST-ACME order=SO-FIN-1 amount=5.0000 USD (plus the AP row from above) - GET /api/v1/finance/journal-entries?orderCode=PO-FIN-1 → only the AP row. - GET /api/v1/finance/journal-entries?type=AR → only the AR row. - platform__event_outbox shows 2 rows (one per confirm) both DISPATCHED, finance__journal_entry shows 2 rows. - The journal-entry code column equals the originating event UUID, proving the dedup contract is wired. What this is NOT (yet) - Not a real general ledger. No debit/credit legs, no chart of accounts, no period close, no double-entry invariant. P5.9 promotes this minimal seed into a real finance PBC. - No reaction to ship/receive/cancel events yet — only confirm. Real revenue recognition (which happens at ship time for most accounting standards) lands with the P5.9 build. - No outbound api.v1.ext facade. pbc-finance does not (yet) expose itself to other PBCs; it is a pure consumer. When pbc-production needs to know "did this order's invoice clear", that facade gets added.