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.