Commit bf090c2e675fb7b69c22502483be04998d18d030

Authored by zichun
1 parent 67406e87

feat(pbc): pbc-finance — first cross-PBC event consumer (minimal AR/AP)

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.
CLAUDE.md
@@ -95,11 +95,11 @@ plugins (incl. ref) depend on: api/api-v1 only @@ -95,11 +95,11 @@ plugins (incl. ref) depend on: api/api-v1 only
95 95
96 **Foundation complete; first business surface in place.** As of the latest commit: 96 **Foundation complete; first business surface in place.** As of the latest commit:
97 97
98 -- **16 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`.  
99 -- **192 unit tests across 16 modules**, all green. `./gradlew build` is the canonical full build. 98 +- **17 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`.
  99 +- **201 unit tests across 17 modules**, all green. `./gradlew build` is the canonical full build.
100 - **All 9 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), authorization with `@RequirePermission` + JWT roles claim + AOP enforcement (P4.3), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader + custom-field validation (P1.5 + P3.4), ICU4J translator with per-plug-in locale chain (P1.6), plus the audit/principal context bridge. 100 - **All 9 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), authorization with `@RequirePermission` + JWT roles claim + AOP enforcement (P4.3), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader + custom-field validation (P1.5 + P3.4), ICU4J translator with per-plug-in locale chain (P1.6), plus the audit/principal context bridge.
101 -- **6 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`). **The full buy-and-sell loop works**: a purchase order receives stock via `PURCHASE_RECEIPT` ledger rows, a sales order ships stock via `SALES_SHIPMENT` ledger rows. Both PBCs feed the same `inventory__stock_movement` ledger via the same `InventoryApi.recordMovement` facade.  
102 -- **Event-driven cross-PBC integration is live.** Six typed events under `org.vibeerp.api.v1.event.orders.*` (`SalesOrderConfirmed/Shipped/Cancelled`, `PurchaseOrderConfirmed/Received/Cancelled`) are published from `SalesOrderService` and `PurchaseOrderService` inside the same `@Transactional` method as their state changes. The wildcard `EventAuditLogSubscriber` logs every one and `platform__event_outbox` rows are persisted + dispatched by the `OutboxPoller`. This is the first end-to-end use of the event bus from real PBC business logic. 101 +- **7 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`). **The full buy-and-sell loop works**: a purchase order receives stock via `PURCHASE_RECEIPT` ledger rows, a sales order ships stock via `SALES_SHIPMENT` ledger rows. Both PBCs feed the same `inventory__stock_movement` ledger via the same `InventoryApi.recordMovement` facade.
  102 +- **Event-driven cross-PBC integration is live in BOTH directions.** Six typed events under `org.vibeerp.api.v1.event.orders.*` are published from `SalesOrderService` and `PurchaseOrderService` inside the same `@Transactional` method as their state changes. The new **pbc-finance** is the framework's first CONSUMER PBC: it subscribes to `SalesOrderConfirmedEvent` and `PurchaseOrderConfirmedEvent` via the api.v1 `EventBus.subscribe(eventType, listener)` typed-class overload and writes idempotent AR/AP `finance__journal_entry` rows tagged with the originating order code. pbc-finance has no source dependency on pbc-orders-* and reaches them only through events. The producer's wildcard `EventAuditLogSubscriber` and the new typed subscribers both fire on every transition, validating the consumer side of the seam end-to-end.
103 - **Reference printing-shop plug-in** owns its own DB schema, CRUDs plates and ink recipes via REST through `context.jdbc`, ships its own metadata YAML, and registers 7 HTTP endpoints. It is the executable acceptance test for the framework. 103 - **Reference printing-shop plug-in** owns its own DB schema, CRUDs plates and ink recipes via REST through `context.jdbc`, ships its own metadata YAML, and registers 7 HTTP endpoints. It is the executable acceptance test for the framework.
104 - **Package root** is `org.vibeerp`. 104 - **Package root** is `org.vibeerp`.
105 - **Build commands:** `./gradlew build` (full), `./gradlew test` (unit tests), `./gradlew :distribution:bootRun` (run dev profile, stages plug-in JAR automatically), `docker compose up -d db` (Postgres). The bootstrap admin password is printed to the application logs on first boot. 105 - **Build commands:** `./gradlew build` (full), `./gradlew test` (unit tests), `./gradlew :distribution:bootRun` (run dev profile, stages plug-in JAR automatically), `docker compose up -d db` (Postgres). The bootstrap admin password is printed to the application logs on first boot.
PROGRESS.md
@@ -10,19 +10,19 @@ @@ -10,19 +10,19 @@
10 10
11 | | | 11 | | |
12 |---|---| 12 |---|---|
13 -| **Latest version** | v0.15 (post-event-driven cross-PBC integration) |  
14 -| **Latest commit** | `b1d433e feat(events): wire SalesOrderService + PurchaseOrderService onto the event bus` | 13 +| **Latest version** | v0.16 (post-pbc-finance: first cross-PBC event consumer) |
  14 +| **Latest commit** | `<pin after push>` |
15 | **Repo** | https://github.com/reporkey/vibe-erp | 15 | **Repo** | https://github.com/reporkey/vibe-erp |
16 -| **Modules** | 16 |  
17 -| **Unit tests** | 192, all green |  
18 -| **End-to-end smoke runs** | The full buy-and-sell loop works AND every state transition publishes a domain event end-to-end: PO confirm/receive/cancel emit `orders_purchase.PurchaseOrder` events, SO confirm/ship/cancel emit `orders_sales.SalesOrder` events. The wildcard `EventAuditLogSubscriber` logs each one and `platform__event_outbox` rows are persisted in the same transaction as the state change and dispatched by the `OutboxPoller`. Smoke verified: 6 events fired, 6 outbox rows DISPATCHED. |  
19 -| **Real PBCs implemented** | 6 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`) | 16 +| **Modules** | 17 |
  17 +| **Unit tests** | 201, all green |
  18 +| **End-to-end smoke runs** | The full buy-and-sell loop works AND every state transition emits a typed domain event AND a separate consumer PBC reacts to those events with no source dependency on the producers. Smoke verified end-to-end: confirming a PO produces an `AP` `finance__journal_entry` row tagged with the PO code, confirming an SO produces an `AR` row, both surfaced via `GET /api/v1/finance/journal-entries`. The new pbc-finance subscribers register at boot via the api.v1 `EventBus.subscribe(eventType, listener)` typed-class overload — no plug-in or platform-internal helper involved. |
  19 +| **Real PBCs implemented** | 7 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`) |
20 | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | 20 | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) |
21 | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. | 21 | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. |
22 22
23 ## Current stage 23 ## Current stage
24 24
25 -**Foundation complete; Tier 1 customization live; authorization enforced; full buy-and-sell loop closed; event-driven cross-PBC integration live.** All eight cross-cutting platform services are live plus the authorization layer. **The framework now has six PBCs, both ends of the inventory loop work, and every state transition emits a typed domain event** through the `EventBus` + transactional outbox. The 6 new events live in `api.v1.event.orders` (`SalesOrderConfirmed/Shipped/Cancelled`, `PurchaseOrderConfirmed/Received/Cancelled`) so any future PBC, plug-in, or subscriber can react without importing pbc-orders-sales or pbc-orders-purchase. Each publish runs inside the same `@Transactional` method as the state change and the ledger writes — a rollback on any line rolls the publish back too. 25 +**Foundation complete; Tier 1 customization live; authorization enforced; full buy-and-sell loop closed; event-driven cross-PBC integration live in BOTH directions.** All eight cross-cutting platform services are live plus the authorization layer. **The framework now has seven PBCs, both ends of the inventory loop work, every state transition emits a typed domain event AND a separate consumer PBC writes derived state in reaction to those events.** The new pbc-finance is the framework's first **consumer** PBC: it has no source dependency on pbc-orders-sales or pbc-orders-purchase but subscribes via the api.v1 `EventBus.subscribe(eventType, listener)` typed-class overload to `SalesOrderConfirmedEvent` and `PurchaseOrderConfirmedEvent`, then writes an AR or AP row to `finance__journal_entry` inside the same transaction as the producer's state change. Idempotent on event id (the unique `code` column) — duplicate deliveries are no-ops.
26 26
27 The next phase continues **building business surface area**: pbc-production (the framework's first non-order/non-master-data PBC), the workflow engine (Flowable), and eventually the React SPA. 27 The next phase continues **building business surface area**: pbc-production (the framework's first non-order/non-master-data PBC), the workflow engine (Flowable), and eventually the React SPA.
28 28
@@ -86,7 +86,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **22 a @@ -86,7 +86,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **22 a
86 | P5.6 | `pbc-orders-purchase` — POs + receive flow (RFQs deferred) | ✅ DONE — `2861656` | 86 | P5.6 | `pbc-orders-purchase` — POs + receive flow (RFQs deferred) | ✅ DONE — `2861656` |
87 | P5.7 | `pbc-production` — work orders, routings, operations | 🔜 Pending | 87 | P5.7 | `pbc-production` — work orders, routings, operations | 🔜 Pending |
88 | P5.8 | `pbc-quality` — inspection plans, results, holds | 🔜 Pending | 88 | P5.8 | `pbc-quality` — inspection plans, results, holds | 🔜 Pending |
89 -| P5.9 | `pbc-finance` — GL, journal entries, AR/AP minimal | 🔜 Pending | 89 +| P5.9 | `pbc-finance` — GL, journal entries, AR/AP minimal | ✅ Partial — minimal AR/AP journal entries driven by events; full GL pending |
90 90
91 ### Phase 6 — Web SPA (React + TS) 91 ### Phase 6 — Web SPA (React + TS)
92 92
@@ -129,7 +129,7 @@ These are the cross-cutting platform services already wired into the running fra @@ -129,7 +129,7 @@ These are the cross-cutting platform services already wired into the running fra
129 | **Metadata loader** (P1.5) | `platform-metadata` | Walks the host classpath and each plug-in JAR for `META-INF/vibe-erp/metadata/*.yml`, upserts entities/permissions/menus into `metadata__*` tables tagged by source. Idempotent (delete-by-source then insert). User-edited metadata (`source='user'`) is never touched. Public `GET /api/v1/_meta/metadata` returns the full set for SPA + AI-agent + OpenAPI introspection. | 129 | **Metadata loader** (P1.5) | `platform-metadata` | Walks the host classpath and each plug-in JAR for `META-INF/vibe-erp/metadata/*.yml`, upserts entities/permissions/menus into `metadata__*` tables tagged by source. Idempotent (delete-by-source then insert). User-edited metadata (`source='user'`) is never touched. Public `GET /api/v1/_meta/metadata` returns the full set for SPA + AI-agent + OpenAPI introspection. |
130 | **i18n / Translator** (P1.6) | `platform-i18n` | ICU4J-backed `Translator` with named placeholders, plurals, gender, locale-aware number/date formatting. Per-plug-in instance scoped via a `(classLoader, baseName)` chain — plug-in's `META-INF/vibe-erp/i18n/messages_<locale>.properties` resolves before the host's `messages_<locale>.properties` for shared keys. `RequestLocaleProvider` reads `Accept-Language` from the active HTTP request via the servlet container, falling back to `vibeerp.i18n.defaultLocale` outside an HTTP context. JVM-default locale fallback explicitly disabled to prevent silent locale leaks. | 130 | **i18n / Translator** (P1.6) | `platform-i18n` | ICU4J-backed `Translator` with named placeholders, plurals, gender, locale-aware number/date formatting. Per-plug-in instance scoped via a `(classLoader, baseName)` chain — plug-in's `META-INF/vibe-erp/i18n/messages_<locale>.properties` resolves before the host's `messages_<locale>.properties` for shared keys. `RequestLocaleProvider` reads `Accept-Language` from the active HTTP request via the servlet container, falling back to `vibeerp.i18n.defaultLocale` outside an HTTP context. JVM-default locale fallback explicitly disabled to prevent silent locale leaks. |
131 | **Custom field application** (P3.4) | `platform-metadata.customfield` | `CustomFieldRegistry` reads `metadata__custom_field` rows into an in-memory index keyed by entity name, refreshed at boot and after every plug-in load. `ExtJsonValidator` validates the JSONB `ext` map of any entity against the declared `FieldType`s — String maxLength, Integer/Decimal numeric coercion with precision/scale enforcement, Boolean, Date/DateTime ISO-8601 parsing, Enum allowed values, UUID format. Unknown keys are rejected; required missing fields are rejected; ALL violations are returned in a single 400 so a form submitter fixes everything in one round-trip. `Partner` is the first PBC entity to wire ext through `PartnerService.create/update`; the public `GET /api/v1/_meta/metadata/custom-fields/{entityName}` endpoint serves the api.v1 runtime view of declarations to the SPA / OpenAPI / AI agent. | 131 | **Custom field application** (P3.4) | `platform-metadata.customfield` | `CustomFieldRegistry` reads `metadata__custom_field` rows into an in-memory index keyed by entity name, refreshed at boot and after every plug-in load. `ExtJsonValidator` validates the JSONB `ext` map of any entity against the declared `FieldType`s — String maxLength, Integer/Decimal numeric coercion with precision/scale enforcement, Boolean, Date/DateTime ISO-8601 parsing, Enum allowed values, UUID format. Unknown keys are rejected; required missing fields are rejected; ALL violations are returned in a single 400 so a form submitter fixes everything in one round-trip. `Partner` is the first PBC entity to wire ext through `PartnerService.create/update`; the public `GET /api/v1/_meta/metadata/custom-fields/{entityName}` endpoint serves the api.v1 runtime view of declarations to the SPA / OpenAPI / AI agent. |
132 -| **PBC pattern** (P5.x recipe) | `pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase` | Six real PBCs prove the recipe. **pbc-orders-purchase** is the buying-side mirror of pbc-orders-sales: same recipe, same three cross-PBC seams (PartnersApi + CatalogApi + InventoryApi), same line-with-recompute pattern, but the partner role check is inverted (must be SUPPLIER or BOTH), the state machine ends in RECEIVED instead of SHIPPED, and the cross-PBC inventory write uses positive deltas with `reason="PURCHASE_RECEIPT"`. The framework's `InventoryApi.recordMovement` facade now has TWO callers — the same primitive feeds the same ledger from both directions. | 132 +| **PBC pattern** (P5.x recipe) | `pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance` | Seven real PBCs prove the recipe. **pbc-orders-purchase** is the buying-side mirror of pbc-orders-sales: same recipe, same three cross-PBC seams (PartnersApi + CatalogApi + InventoryApi), same line-with-recompute pattern, but the partner role check is inverted (must be SUPPLIER or BOTH), the state machine ends in RECEIVED instead of SHIPPED, and the cross-PBC inventory write uses positive deltas with `reason="PURCHASE_RECEIPT"`. **pbc-finance** is the framework's first CONSUMER PBC — it has no service of its own (yet), no cross-PBC facade in `api.v1.ext.*`, and no write endpoint. It exists ONLY to react to `SalesOrderConfirmedEvent` and `PurchaseOrderConfirmedEvent` from the api.v1 event surface and produce derived AR/AP rows. This validates the consumer side of the cross-PBC seam: a brand-new PBC subscribes to existing PBCs' events through `EventBus.subscribe(eventType, listener)` without any source dependency on the producers. |
133 133
134 ## What the reference plug-in proves end-to-end 134 ## What the reference plug-in proves end-to-end
135 135
@@ -222,6 +222,10 @@ pbc/pbc-orders-sales SalesOrder + SalesOrderLine + cross-PBC SalesOrder @@ -222,6 +222,10 @@ pbc/pbc-orders-sales SalesOrder + SalesOrderLine + cross-PBC SalesOrder
222 pbc/pbc-orders-purchase PurchaseOrder + PurchaseOrderLine + cross-PBC PurchaseOrdersApi facade 222 pbc/pbc-orders-purchase PurchaseOrder + PurchaseOrderLine + cross-PBC PurchaseOrdersApi facade
223 (the buying-side mirror; receives via InventoryApi.recordMovement 223 (the buying-side mirror; receives via InventoryApi.recordMovement
224 with positive PURCHASE_RECEIPT deltas) 224 with positive PURCHASE_RECEIPT deltas)
  225 +pbc/pbc-finance JournalEntry + read-only controller; first CONSUMER PBC.
  226 + Subscribes to SalesOrderConfirmedEvent + PurchaseOrderConfirmedEvent
  227 + via api.v1 EventBus.subscribe(eventType, listener) and writes
  228 + idempotent AR/AP rows. No outbound facade.
225 229
226 reference-customer/plugin-printing-shop 230 reference-customer/plugin-printing-shop
227 Reference plug-in: own DB schema (plate, ink_recipe), 231 Reference plug-in: own DB schema (plate, ink_recipe),
@@ -230,7 +234,7 @@ reference-customer/plugin-printing-shop @@ -230,7 +234,7 @@ reference-customer/plugin-printing-shop
230 distribution Bootable Spring Boot fat-jar assembly 234 distribution Bootable Spring Boot fat-jar assembly
231 ``` 235 ```
232 236
233 -16 Gradle subprojects. Architectural dependency rule (PBCs never import each other; plug-ins only see api.v1) is enforced by the root `build.gradle.kts` at configuration time. 237 +17 Gradle subprojects. Architectural dependency rule (PBCs never import each other; plug-ins only see api.v1) is enforced by the root `build.gradle.kts` at configuration time.
234 238
235 ## Where to look next 239 ## Where to look next
236 240
README.md
@@ -77,7 +77,7 @@ vibe-erp/ @@ -77,7 +77,7 @@ vibe-erp/
77 ## Building 77 ## Building
78 78
79 ```bash 79 ```bash
80 -# Build everything (compiles 16 modules, runs 192 unit tests) 80 +# Build everything (compiles 17 modules, runs 201 unit tests)
81 ./gradlew build 81 ./gradlew build
82 82
83 # Bring up Postgres + the reference plug-in JAR 83 # Bring up Postgres + the reference plug-in JAR
@@ -96,9 +96,9 @@ The bootstrap admin password is printed to the application logs on first boot. A @@ -96,9 +96,9 @@ The bootstrap admin password is printed to the application logs on first boot. A
96 96
97 | | | 97 | | |
98 |---|---| 98 |---|---|
99 -| Modules | 16 |  
100 -| Unit tests | 192, all green |  
101 -| Real PBCs | 6 of 10 | 99 +| Modules | 17 |
  100 +| Unit tests | 201, all green |
  101 +| Real PBCs | 7 of 10 |
102 | Cross-cutting services live | 9 | 102 | Cross-cutting services live | 9 |
103 | Plug-ins serving HTTP | 1 (reference printing-shop) | 103 | Plug-ins serving HTTP | 1 (reference printing-shop) |
104 | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending | 104 | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending |
distribution/build.gradle.kts
@@ -32,6 +32,7 @@ dependencies { @@ -32,6 +32,7 @@ dependencies {
32 implementation(project(":pbc:pbc-inventory")) 32 implementation(project(":pbc:pbc-inventory"))
33 implementation(project(":pbc:pbc-orders-sales")) 33 implementation(project(":pbc:pbc-orders-sales"))
34 implementation(project(":pbc:pbc-orders-purchase")) 34 implementation(project(":pbc:pbc-orders-purchase"))
  35 + implementation(project(":pbc:pbc-finance"))
35 36
36 implementation(libs.spring.boot.starter) 37 implementation(libs.spring.boot.starter)
37 implementation(libs.spring.boot.starter.web) 38 implementation(libs.spring.boot.starter.web)
distribution/src/main/resources/db/changelog/master.xml
@@ -20,4 +20,5 @@ @@ -20,4 +20,5 @@
20 <include file="classpath:db/changelog/pbc-inventory/002-inventory-movement-ledger.xml"/> 20 <include file="classpath:db/changelog/pbc-inventory/002-inventory-movement-ledger.xml"/>
21 <include file="classpath:db/changelog/pbc-orders-sales/001-orders-sales-init.xml"/> 21 <include file="classpath:db/changelog/pbc-orders-sales/001-orders-sales-init.xml"/>
22 <include file="classpath:db/changelog/pbc-orders-purchase/001-orders-purchase-init.xml"/> 22 <include file="classpath:db/changelog/pbc-orders-purchase/001-orders-purchase-init.xml"/>
  23 + <include file="classpath:db/changelog/pbc-finance/001-finance-init.xml"/>
23 </databaseChangeLog> 24 </databaseChangeLog>
distribution/src/main/resources/db/changelog/pbc-finance/001-finance-init.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
  3 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  4 + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
  5 + https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
  6 +
  7 + <!--
  8 + pbc-finance initial schema (minimal AR/AP, v0.16).
  9 +
  10 + Owns: finance__journal_entry.
  11 +
  12 + This is the framework's first CONSUMER PBC: the table only ever
  13 + receives writes from event subscribers (see
  14 + org.vibeerp.pbc.finance.event.OrderEventSubscribers). The
  15 + unique constraint on `code` is the durability anchor for
  16 + idempotent event delivery — a duplicate event with the same
  17 + eventId is rejected by Postgres rather than producing a second
  18 + row. JournalEntryService.recordSalesConfirmed/recordPurchaseConfirmed
  19 + also do an existsByCode pre-check so the duplicate path is a
  20 + clean no-op rather than a constraint-violation exception.
  21 +
  22 + NEITHER `partner_code` NOR `order_code` is a foreign key. They
  23 + are cross-PBC references; a database FK across PBCs would
  24 + couple the schemas of pbc-finance, pbc-partners, pbc-orders-sales
  25 + and pbc-orders-purchase at the storage level, defeating the
  26 + bounded-context rule (CLAUDE.md guardrail #9).
  27 + -->
  28 +
  29 + <changeSet id="finance-init-001" author="vibe_erp">
  30 + <comment>Create finance__journal_entry table</comment>
  31 + <sql>
  32 + CREATE TABLE finance__journal_entry (
  33 + id uuid PRIMARY KEY,
  34 + code varchar(64) NOT NULL,
  35 + type varchar(8) NOT NULL,
  36 + partner_code varchar(64) NOT NULL,
  37 + order_code varchar(64) NOT NULL,
  38 + amount numeric(18,4) NOT NULL,
  39 + currency_code varchar(3) NOT NULL,
  40 + posted_at timestamptz NOT NULL,
  41 + ext jsonb NOT NULL DEFAULT '{}'::jsonb,
  42 + created_at timestamptz NOT NULL,
  43 + created_by varchar(128) NOT NULL,
  44 + updated_at timestamptz NOT NULL,
  45 + updated_by varchar(128) NOT NULL,
  46 + version bigint NOT NULL DEFAULT 0,
  47 + CONSTRAINT finance__journal_entry_type_check CHECK (type IN ('AR', 'AP'))
  48 + );
  49 + CREATE UNIQUE INDEX finance__journal_entry_code_uk
  50 + ON finance__journal_entry (code);
  51 + CREATE INDEX finance__journal_entry_partner_idx
  52 + ON finance__journal_entry (partner_code);
  53 + CREATE INDEX finance__journal_entry_order_idx
  54 + ON finance__journal_entry (order_code);
  55 + CREATE INDEX finance__journal_entry_type_idx
  56 + ON finance__journal_entry (type);
  57 + CREATE INDEX finance__journal_entry_posted_idx
  58 + ON finance__journal_entry (posted_at);
  59 + CREATE INDEX finance__journal_entry_ext_gin
  60 + ON finance__journal_entry USING GIN (ext jsonb_path_ops);
  61 + </sql>
  62 + <rollback>
  63 + DROP TABLE finance__journal_entry;
  64 + </rollback>
  65 + </changeSet>
  66 +
  67 +</databaseChangeLog>
pbc/pbc-finance/build.gradle.kts 0 → 100644
  1 +plugins {
  2 + alias(libs.plugins.kotlin.jvm)
  3 + alias(libs.plugins.kotlin.spring)
  4 + alias(libs.plugins.kotlin.jpa)
  5 + alias(libs.plugins.spring.dependency.management)
  6 +}
  7 +
  8 +description = "vibe_erp pbc-finance — minimal AR/AP journal entries driven by domain events from sales and purchase orders. INTERNAL Packaged Business Capability."
  9 +
  10 +java {
  11 + toolchain {
  12 + languageVersion.set(JavaLanguageVersion.of(21))
  13 + }
  14 +}
  15 +
  16 +kotlin {
  17 + jvmToolchain(21)
  18 + compilerOptions {
  19 + freeCompilerArgs.add("-Xjsr305=strict")
  20 + }
  21 +}
  22 +
  23 +allOpen {
  24 + annotation("jakarta.persistence.Entity")
  25 + annotation("jakarta.persistence.MappedSuperclass")
  26 + annotation("jakarta.persistence.Embeddable")
  27 +}
  28 +
  29 +// pbc-finance is the framework's first CONSUMER PBC: it doesn't expose
  30 +// any cross-PBC service of its own (yet) but instead reacts to events
  31 +// published by other PBCs through api.v1.event.orders.*. Like every
  32 +// other PBC, it must NOT depend on another pbc-* — the dependency rule
  33 +// is enforced by the root build. Inbound events arrive via the
  34 +// platform-events EventBus interface defined in api.v1.
  35 +dependencies {
  36 + api(project(":api:api-v1"))
  37 + implementation(project(":platform:platform-persistence"))
  38 + implementation(project(":platform:platform-security"))
  39 +
  40 + implementation(libs.kotlin.stdlib)
  41 + implementation(libs.kotlin.reflect)
  42 +
  43 + implementation(libs.spring.boot.starter)
  44 + implementation(libs.spring.boot.starter.web)
  45 + implementation(libs.spring.boot.starter.data.jpa)
  46 + implementation(libs.spring.boot.starter.validation)
  47 + implementation(libs.jackson.module.kotlin)
  48 +
  49 + testImplementation(libs.spring.boot.starter.test)
  50 + testImplementation(libs.junit.jupiter)
  51 + testImplementation(libs.assertk)
  52 + testImplementation(libs.mockk)
  53 +}
  54 +
  55 +tasks.test {
  56 + useJUnitPlatform()
  57 +}
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/application/JournalEntryService.kt 0 → 100644
  1 +package org.vibeerp.pbc.finance.application
  2 +
  3 +import org.slf4j.LoggerFactory
  4 +import org.springframework.stereotype.Service
  5 +import org.springframework.transaction.annotation.Propagation
  6 +import org.springframework.transaction.annotation.Transactional
  7 +import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent
  8 +import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent
  9 +import org.vibeerp.pbc.finance.domain.JournalEntry
  10 +import org.vibeerp.pbc.finance.domain.JournalEntryType
  11 +import org.vibeerp.pbc.finance.infrastructure.JournalEntryJpaRepository
  12 +import java.util.UUID
  13 +
  14 +/**
  15 + * Application service for [JournalEntry] writes triggered by
  16 + * cross-PBC domain events.
  17 + *
  18 + * **Why these methods take the event as the input rather than a
  19 + * separate command DTO:** the events ARE the command. The whole
  20 + * point of cross-PBC integration is that the producer's event
  21 + * carries everything the consumer needs — there is no separate
  22 + * REST request, and inventing a parallel command type would just
  23 + * be ceremony. The api.v1 events are stable contracts (semver-
  24 + * governed) so binding pbc-finance to them carries the same upgrade
  25 + * cost as binding to any other api.v1 surface.
  26 + *
  27 + * **Transaction propagation: REQUIRED.** Per the EventListener KDoc,
  28 + * a listener must NOT assume the publisher's transaction is still
  29 + * open. Today the in-process EventBusImpl delivers synchronously
  30 + * inside the publisher's transaction, so REQUIRED simply joins it
  31 + * and the journal entry shares the same atomic boundary as the
  32 + * order's status change. If a future async bus delivers events from
  33 + * a worker thread without an active transaction, REQUIRED creates a
  34 + * fresh one — the same code keeps working under both delivery
  35 + * models. Using REQUIRES_NEW would always create a fresh transaction
  36 + * even today, breaking the desirable atomicity-with-the-publisher
  37 + * for the synchronous case. REQUIRED is the right default.
  38 + *
  39 + * **Idempotency.** Each method short-circuits if a row already
  40 + * exists for the event's id (the `code` column). The framework's
  41 + * outbox could deliver an event twice (a Kafka bridge retry, an
  42 + * outbox replay after crash) and the consumer must remain correct.
  43 + */
  44 +@Service
  45 +@Transactional(propagation = Propagation.REQUIRED)
  46 +class JournalEntryService(
  47 + private val entries: JournalEntryJpaRepository,
  48 +) {
  49 +
  50 + private val log = LoggerFactory.getLogger(JournalEntryService::class.java)
  51 +
  52 + /**
  53 + * React to a `SalesOrderConfirmedEvent` by writing an AR row.
  54 + *
  55 + * The customer now owes us the order total — that's the entire
  56 + * accounting story for the v0.16 minimal finance PBC. Real
  57 + * revenue recognition (which happens at SHIP time, not CONFIRM
  58 + * time, for most accounting standards) lives in the future
  59 + * P5.9 build.
  60 + */
  61 + fun recordSalesConfirmed(event: SalesOrderConfirmedEvent): JournalEntry? {
  62 + val code = event.eventId.value.toString()
  63 + if (entries.existsByCode(code)) {
  64 + log.debug(
  65 + "[finance] dropping duplicate SalesOrderConfirmedEvent eventId={} orderCode={}",
  66 + code, event.orderCode,
  67 + )
  68 + return null
  69 + }
  70 + log.info(
  71 + "[finance] AR ← SalesOrderConfirmedEvent orderCode={} partnerCode={} amount={} {}",
  72 + event.orderCode, event.partnerCode, event.totalAmount, event.currencyCode,
  73 + )
  74 + return entries.save(
  75 + JournalEntry(
  76 + code = code,
  77 + type = JournalEntryType.AR,
  78 + partnerCode = event.partnerCode,
  79 + orderCode = event.orderCode,
  80 + amount = event.totalAmount,
  81 + currencyCode = event.currencyCode,
  82 + postedAt = event.occurredAt,
  83 + ),
  84 + )
  85 + }
  86 +
  87 + /**
  88 + * React to a `PurchaseOrderConfirmedEvent` by writing an AP row.
  89 + *
  90 + * Mirror of [recordSalesConfirmed] for the buying side. The
  91 + * supplier is now owed the order total. Real cash-flow timing
  92 + * (and the actual payable becoming payable on receipt or
  93 + * invoice match) lives in the future P5.9 build.
  94 + */
  95 + fun recordPurchaseConfirmed(event: PurchaseOrderConfirmedEvent): JournalEntry? {
  96 + val code = event.eventId.value.toString()
  97 + if (entries.existsByCode(code)) {
  98 + log.debug(
  99 + "[finance] dropping duplicate PurchaseOrderConfirmedEvent eventId={} orderCode={}",
  100 + code, event.orderCode,
  101 + )
  102 + return null
  103 + }
  104 + log.info(
  105 + "[finance] AP ← PurchaseOrderConfirmedEvent orderCode={} partnerCode={} amount={} {}",
  106 + event.orderCode, event.partnerCode, event.totalAmount, event.currencyCode,
  107 + )
  108 + return entries.save(
  109 + JournalEntry(
  110 + code = code,
  111 + type = JournalEntryType.AP,
  112 + partnerCode = event.partnerCode,
  113 + orderCode = event.orderCode,
  114 + amount = event.totalAmount,
  115 + currencyCode = event.currencyCode,
  116 + postedAt = event.occurredAt,
  117 + ),
  118 + )
  119 + }
  120 +
  121 + @Transactional(readOnly = true)
  122 + fun list(): List<JournalEntry> = entries.findAll()
  123 +
  124 + @Transactional(readOnly = true)
  125 + fun findById(id: UUID): JournalEntry? = entries.findById(id).orElse(null)
  126 +
  127 + @Transactional(readOnly = true)
  128 + fun findByOrderCode(orderCode: String): List<JournalEntry> = entries.findByOrderCode(orderCode)
  129 +
  130 + @Transactional(readOnly = true)
  131 + fun findByType(type: JournalEntryType): List<JournalEntry> = entries.findByType(type)
  132 +}
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/JournalEntry.kt 0 → 100644
  1 +package org.vibeerp.pbc.finance.domain
  2 +
  3 +import jakarta.persistence.Column
  4 +import jakarta.persistence.Entity
  5 +import jakarta.persistence.EnumType
  6 +import jakarta.persistence.Enumerated
  7 +import jakarta.persistence.Table
  8 +import org.hibernate.annotations.JdbcTypeCode
  9 +import org.hibernate.type.SqlTypes
  10 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  11 +import java.math.BigDecimal
  12 +import java.time.Instant
  13 +
  14 +/**
  15 + * A single accounting row produced by pbc-finance in reaction to a
  16 + * domain event from another PBC.
  17 + *
  18 + * **What this is — and what it deliberately is NOT.**
  19 + * - It IS a minimal journal-entry table sufficient to demonstrate
  20 + * that **a NEW PBC can subscribe to events from existing PBCs
  21 + * without importing them**. That is the entire purpose of this
  22 + * chunk: validate the consumer side of the cross-PBC event seam
  23 + * that landed in commit `67406e8`.
  24 + * - It is NOT a real general ledger. There are no debit/credit
  25 + * legs, no chart of accounts, no period close, no double-entry
  26 + * invariant enforcement. P5.9 is the chunk that grows this into
  27 + * a real finance PBC; this row exists so that "confirmed sales
  28 + * order produces a finance side-effect" is observable end-to-end.
  29 + *
  30 + * **Idempotency by event id.** The [code] column carries the
  31 + * originating event's UUID, with a unique index. If the same event
  32 + * is delivered twice (e.g. an outbox replay after a crash, or a
  33 + * future Kafka bridge retry) the second insert is a no-op. This is
  34 + * how we make event subscribers safe under at-least-once delivery
  35 + * without growing a separate dedup table.
  36 + *
  37 + * **Cross-PBC reference policy.** [partnerCode] and [orderCode] are
  38 + * stored as strings, not foreign keys. The PBC that owns the partner
  39 + * (`pbc-partners`) and the PBC that owns the order (`pbc-orders-sales`
  40 + * or `pbc-orders-purchase`) are separate bounded contexts; a database
  41 + * FK across PBCs would couple their schemas at the storage level
  42 + * and defeat the bounded-context rule (CLAUDE.md guardrail #9).
  43 + */
  44 +@Entity
  45 +@Table(name = "finance__journal_entry")
  46 +class JournalEntry(
  47 + code: String,
  48 + type: JournalEntryType,
  49 + partnerCode: String,
  50 + orderCode: String,
  51 + amount: BigDecimal,
  52 + currencyCode: String,
  53 + postedAt: Instant,
  54 +) : AuditedJpaEntity() {
  55 +
  56 + @Column(name = "code", nullable = false, length = 64)
  57 + var code: String = code
  58 +
  59 + @Enumerated(EnumType.STRING)
  60 + @Column(name = "type", nullable = false, length = 8)
  61 + var type: JournalEntryType = type
  62 +
  63 + @Column(name = "partner_code", nullable = false, length = 64)
  64 + var partnerCode: String = partnerCode
  65 +
  66 + @Column(name = "order_code", nullable = false, length = 64)
  67 + var orderCode: String = orderCode
  68 +
  69 + @Column(name = "amount", nullable = false, precision = 18, scale = 4)
  70 + var amount: BigDecimal = amount
  71 +
  72 + @Column(name = "currency_code", nullable = false, length = 3)
  73 + var currencyCode: String = currencyCode
  74 +
  75 + @Column(name = "posted_at", nullable = false)
  76 + var postedAt: Instant = postedAt
  77 +
  78 + @Column(name = "ext", nullable = false, columnDefinition = "jsonb")
  79 + @JdbcTypeCode(SqlTypes.JSON)
  80 + var ext: String = "{}"
  81 +
  82 + override fun toString(): String =
  83 + "JournalEntry(id=$id, code='$code', type=$type, partner='$partnerCode', order='$orderCode', amount=$amount $currencyCode)"
  84 +}
  85 +
  86 +/**
  87 + * The accounting direction the entry represents.
  88 + *
  89 + * - **AR** (Accounts Receivable) — money the company is owed.
  90 + * Produced by `SalesOrderConfirmedEvent`: when we confirm a sales
  91 + * order to a customer, the customer now owes us the order total.
  92 + * - **AP** (Accounts Payable) — money the company owes someone.
  93 + * Produced by `PurchaseOrderConfirmedEvent`: when we confirm a PO
  94 + * to a supplier, we now owe the supplier the order total.
  95 + *
  96 + * Receipt and shipment events do NOT (yet) generate journal entries.
  97 + * That belongs to the real P5.9 finance build, where shipping
  98 + * recognises revenue and receiving recognises inventory cost — both
  99 + * of which require a real chart of accounts. The minimal v0.16
  100 + * version stops at "an order was confirmed → an AR/AP row exists".
  101 + */
  102 +enum class JournalEntryType {
  103 + AR,
  104 + AP,
  105 +}
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/event/OrderEventSubscribers.kt 0 → 100644
  1 +package org.vibeerp.pbc.finance.event
  2 +
  3 +import jakarta.annotation.PostConstruct
  4 +import org.slf4j.LoggerFactory
  5 +import org.springframework.stereotype.Component
  6 +import org.vibeerp.api.v1.event.EventBus
  7 +import org.vibeerp.api.v1.event.EventListener
  8 +import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent
  9 +import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent
  10 +import org.vibeerp.pbc.finance.application.JournalEntryService
  11 +
  12 +/**
  13 + * Subscribes pbc-finance to the two cross-PBC events it cares about
  14 + * and forwards each delivery to [JournalEntryService].
  15 + *
  16 + * **The framework's first cross-PBC event subscriber.** The
  17 + * EventBus seam has existed since P1.7, but the only existing
  18 + * subscriber (`EventAuditLogSubscriber`) lives in `platform-events`
  19 + * and uses the wildcard `subscribeToAll` helper that's deliberately
  20 + * not exposed to PBCs. This component proves the **public,
  21 + * api.v1-only** subscribe path: a brand-new PBC, with no source
  22 + * dependency on the producing PBCs, picks up exactly the typed
  23 + * events it cares about by their event class.
  24 + *
  25 + * **Why one bean with two subscriptions, instead of two beans:**
  26 + * - Both subscriptions share the same `JournalEntryService` and the
  27 + * same registration moment. Splitting them into two `@Component`
  28 + * classes would be cargo-culted symmetry without ergonomic gain.
  29 + * - When more events join (shipped, received, cancelled), they
  30 + * accumulate as additional `subscribe(...)` calls in the same
  31 + * `@PostConstruct` block — one place to read, one place to grep.
  32 + *
  33 + * **Why `@PostConstruct` instead of an `ApplicationListener`-style
  34 + * registration:** the `EventBus` interface is the public api.v1
  35 + * surface; using a Spring-internal `ApplicationEventPublisher` would
  36 + * defeat the point of the chunk (proving that pbc-finance can wire
  37 + * itself through the framework's stable event seam, not Spring's).
  38 + *
  39 + * **Subscription handles are intentionally NOT stored.** Per the
  40 + * `EventBus.Subscription` KDoc, the platform tears down a stopped
  41 + * plug-in's listeners automatically. pbc-finance is a built-in PBC
  42 + * that runs for the lifetime of the process, so there's no
  43 + * deregistration moment we need to handle. If a future feature
  44 + * (feature flags, hot-reload) needs runtime deregistration, the
  45 + * obvious change is to keep the [EventBus.Subscription] handles in
  46 + * a `MutableList` and call `close()` from a `@PreDestroy`.
  47 + */
  48 +@Component
  49 +class OrderEventSubscribers(
  50 + private val eventBus: EventBus,
  51 + private val journal: JournalEntryService,
  52 +) {
  53 +
  54 + private val log = LoggerFactory.getLogger(OrderEventSubscribers::class.java)
  55 +
  56 + @PostConstruct
  57 + fun subscribe() {
  58 + eventBus.subscribe(
  59 + SalesOrderConfirmedEvent::class.java,
  60 + EventListener { event -> journal.recordSalesConfirmed(event) },
  61 + )
  62 + eventBus.subscribe(
  63 + PurchaseOrderConfirmedEvent::class.java,
  64 + EventListener { event -> journal.recordPurchaseConfirmed(event) },
  65 + )
  66 + log.info(
  67 + "pbc-finance subscribed to SalesOrderConfirmedEvent and PurchaseOrderConfirmedEvent " +
  68 + "via EventBus.subscribe (typed-class overload)",
  69 + )
  70 + }
  71 +}
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/http/JournalEntryController.kt 0 → 100644
  1 +package org.vibeerp.pbc.finance.http
  2 +
  3 +import org.springframework.http.ResponseEntity
  4 +import org.springframework.web.bind.annotation.GetMapping
  5 +import org.springframework.web.bind.annotation.PathVariable
  6 +import org.springframework.web.bind.annotation.RequestMapping
  7 +import org.springframework.web.bind.annotation.RequestParam
  8 +import org.springframework.web.bind.annotation.RestController
  9 +import org.vibeerp.pbc.finance.application.JournalEntryService
  10 +import org.vibeerp.pbc.finance.domain.JournalEntry
  11 +import org.vibeerp.pbc.finance.domain.JournalEntryType
  12 +import org.vibeerp.platform.security.authz.RequirePermission
  13 +import java.math.BigDecimal
  14 +import java.time.Instant
  15 +import java.util.UUID
  16 +
  17 +/**
  18 + * Read-only REST API for [JournalEntry].
  19 + *
  20 + * **Read-only on purpose.** Journal entries in pbc-finance are
  21 + * derived state — they only ever come into existence as the
  22 + * downstream effect of a domain event from another PBC. There is no
  23 + * `POST /api/v1/finance/journal-entries` because creating one
  24 + * by hand would defeat the entire seam being demonstrated. Future
  25 + * adjustments and reversals will land as their own command verbs
  26 + * (`adjust`, `reverse`) when the real P5.9 finance build needs
  27 + * them, not as a generic create endpoint.
  28 + *
  29 + * **Why under `/api/v1/finance`** instead of `/api/v1/finance/`
  30 + * (with a trailing slash) or `/api/v1/finance/ledger`: every other
  31 + * PBC follows `/api/v1/<pbc-shortname>/<resource>` and finance
  32 + * follows the same convention. The "ledger" subpath would be
  33 + * misleading because v0.16 has no real ledger — only journal
  34 + * entries.
  35 + */
  36 +@RestController
  37 +@RequestMapping("/api/v1/finance/journal-entries")
  38 +class JournalEntryController(
  39 + private val journalEntryService: JournalEntryService,
  40 +) {
  41 +
  42 + @GetMapping
  43 + @RequirePermission("finance.journal.read")
  44 + fun list(
  45 + @RequestParam(required = false) orderCode: String?,
  46 + @RequestParam(required = false) type: JournalEntryType?,
  47 + ): List<JournalEntryResponse> {
  48 + val rows = when {
  49 + orderCode != null -> journalEntryService.findByOrderCode(orderCode)
  50 + type != null -> journalEntryService.findByType(type)
  51 + else -> journalEntryService.list()
  52 + }
  53 + return rows.map { it.toResponse() }
  54 + }
  55 +
  56 + @GetMapping("/{id}")
  57 + @RequirePermission("finance.journal.read")
  58 + fun get(@PathVariable id: UUID): ResponseEntity<JournalEntryResponse> {
  59 + val entry = journalEntryService.findById(id) ?: return ResponseEntity.notFound().build()
  60 + return ResponseEntity.ok(entry.toResponse())
  61 + }
  62 +}
  63 +
  64 +data class JournalEntryResponse(
  65 + val id: UUID,
  66 + val code: String,
  67 + val type: JournalEntryType,
  68 + val partnerCode: String,
  69 + val orderCode: String,
  70 + val amount: BigDecimal,
  71 + val currencyCode: String,
  72 + val postedAt: Instant,
  73 +)
  74 +
  75 +private fun JournalEntry.toResponse(): JournalEntryResponse =
  76 + JournalEntryResponse(
  77 + id = id,
  78 + code = code,
  79 + type = type,
  80 + partnerCode = partnerCode,
  81 + orderCode = orderCode,
  82 + amount = amount,
  83 + currencyCode = currencyCode,
  84 + postedAt = postedAt,
  85 + )
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/infrastructure/JournalEntryJpaRepository.kt 0 → 100644
  1 +package org.vibeerp.pbc.finance.infrastructure
  2 +
  3 +import org.springframework.data.jpa.repository.JpaRepository
  4 +import org.springframework.stereotype.Repository
  5 +import org.vibeerp.pbc.finance.domain.JournalEntry
  6 +import org.vibeerp.pbc.finance.domain.JournalEntryType
  7 +import java.util.UUID
  8 +
  9 +/**
  10 + * Spring Data JPA repository for [JournalEntry].
  11 + *
  12 + * The `existsByCode` query is the dedup hook used by
  13 + * [org.vibeerp.pbc.finance.application.JournalEntryService] to make
  14 + * event delivery idempotent — a second copy of the same event is a
  15 + * no-op. The unique constraint on `finance__journal_entry.code`
  16 + * (defined in the Liquibase changelog) is the durability anchor;
  17 + * the existsByCode check is the clean-error path.
  18 + */
  19 +@Repository
  20 +interface JournalEntryJpaRepository : JpaRepository<JournalEntry, UUID> {
  21 + fun existsByCode(code: String): Boolean
  22 + fun findByCode(code: String): JournalEntry?
  23 + fun findByOrderCode(orderCode: String): List<JournalEntry>
  24 + fun findByType(type: JournalEntryType): List<JournalEntry>
  25 +}
pbc/pbc-finance/src/main/resources/META-INF/vibe-erp/metadata/finance.yml 0 → 100644
  1 +# pbc-finance metadata.
  2 +#
  3 +# Loaded at boot by MetadataLoader, tagged source='core'. The minimal
  4 +# v0.16 build only carries the JournalEntry entity, the read permission,
  5 +# and a navigation menu entry. No write permissions exist because there
  6 +# is no write endpoint — entries appear automatically in reaction to
  7 +# domain events from other PBCs (SalesOrderConfirmed → AR row,
  8 +# PurchaseOrderConfirmed → AP row).
  9 +
  10 +entities:
  11 + - name: JournalEntry
  12 + pbc: finance
  13 + table: finance__journal_entry
  14 + description: A single AR/AP journal entry derived from a sales- or purchase-order confirmation event
  15 +
  16 +permissions:
  17 + - key: finance.journal.read
  18 + description: Read journal entries
  19 +
  20 +menus:
  21 + - path: /finance/journal-entries
  22 + label: Journal entries
  23 + icon: book-open
  24 + section: Finance
  25 + order: 700
pbc/pbc-finance/src/test/kotlin/org/vibeerp/pbc/finance/application/JournalEntryServiceTest.kt 0 → 100644
  1 +package org.vibeerp.pbc.finance.application
  2 +
  3 +import assertk.assertThat
  4 +import assertk.assertions.isEqualTo
  5 +import assertk.assertions.isNotNull
  6 +import assertk.assertions.isNull
  7 +import io.mockk.every
  8 +import io.mockk.mockk
  9 +import io.mockk.slot
  10 +import io.mockk.verify
  11 +import org.junit.jupiter.api.BeforeEach
  12 +import org.junit.jupiter.api.Test
  13 +import org.vibeerp.api.v1.core.Id
  14 +import org.vibeerp.api.v1.event.DomainEvent
  15 +import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent
  16 +import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent
  17 +import org.vibeerp.pbc.finance.domain.JournalEntry
  18 +import org.vibeerp.pbc.finance.domain.JournalEntryType
  19 +import org.vibeerp.pbc.finance.infrastructure.JournalEntryJpaRepository
  20 +import java.math.BigDecimal
  21 +import java.time.Instant
  22 +import java.util.UUID
  23 +
  24 +class JournalEntryServiceTest {
  25 +
  26 + private lateinit var entries: JournalEntryJpaRepository
  27 + private lateinit var service: JournalEntryService
  28 +
  29 + @BeforeEach
  30 + fun setUp() {
  31 + entries = mockk()
  32 + every { entries.existsByCode(any()) } returns false
  33 + every { entries.save(any<JournalEntry>()) } answers { firstArg() }
  34 + service = JournalEntryService(entries)
  35 + }
  36 +
  37 + private fun salesEvent(
  38 + eventId: UUID = UUID.randomUUID(),
  39 + orderCode: String = "SO-1",
  40 + partnerCode: String = "CUST-1",
  41 + currency: String = "USD",
  42 + total: String = "100.00",
  43 + occurredAt: Instant = Instant.parse("2026-04-08T10:00:00Z"),
  44 + ) = SalesOrderConfirmedEvent(
  45 + orderCode = orderCode,
  46 + partnerCode = partnerCode,
  47 + currencyCode = currency,
  48 + totalAmount = BigDecimal(total),
  49 + eventId = Id(eventId),
  50 + occurredAt = occurredAt,
  51 + )
  52 +
  53 + private fun purchaseEvent(
  54 + eventId: UUID = UUID.randomUUID(),
  55 + orderCode: String = "PO-1",
  56 + partnerCode: String = "SUP-1",
  57 + currency: String = "USD",
  58 + total: String = "550.00",
  59 + occurredAt: Instant = Instant.parse("2026-04-08T11:00:00Z"),
  60 + ) = PurchaseOrderConfirmedEvent(
  61 + orderCode = orderCode,
  62 + partnerCode = partnerCode,
  63 + currencyCode = currency,
  64 + totalAmount = BigDecimal(total),
  65 + eventId = Id(eventId),
  66 + occurredAt = occurredAt,
  67 + )
  68 +
  69 + // ─── recordSalesConfirmed ────────────────────────────────────────
  70 +
  71 + @Test
  72 + fun `recordSalesConfirmed writes an AR row carrying every event field`() {
  73 + val eventId = UUID.fromString("11111111-1111-4111-8111-111111111111")
  74 + val event = salesEvent(
  75 + eventId = eventId,
  76 + orderCode = "SO-42",
  77 + partnerCode = "CUST-ACME",
  78 + currency = "USD",
  79 + total = "1234.56",
  80 + occurredAt = Instant.parse("2026-04-08T12:34:56Z"),
  81 + )
  82 + val saved = slot<JournalEntry>()
  83 + every { entries.save(capture(saved)) } answers { saved.captured }
  84 +
  85 + val result = service.recordSalesConfirmed(event)
  86 +
  87 + assertThat(result).isNotNull()
  88 + with(saved.captured) {
  89 + assertThat(code).isEqualTo(eventId.toString())
  90 + assertThat(type).isEqualTo(JournalEntryType.AR)
  91 + assertThat(orderCode).isEqualTo("SO-42")
  92 + assertThat(partnerCode).isEqualTo("CUST-ACME")
  93 + assertThat(amount).isEqualTo(BigDecimal("1234.56"))
  94 + assertThat(currencyCode).isEqualTo("USD")
  95 + assertThat(postedAt).isEqualTo(Instant.parse("2026-04-08T12:34:56Z"))
  96 + }
  97 + }
  98 +
  99 + @Test
  100 + fun `recordSalesConfirmed is idempotent when an entry with the same eventId already exists`() {
  101 + val eventId = UUID.fromString("22222222-2222-4222-8222-222222222222")
  102 + every { entries.existsByCode(eventId.toString()) } returns true
  103 +
  104 + val result = service.recordSalesConfirmed(salesEvent(eventId = eventId))
  105 +
  106 + assertThat(result).isNull()
  107 + verify(exactly = 0) { entries.save(any<JournalEntry>()) }
  108 + }
  109 +
  110 + // ─── recordPurchaseConfirmed ─────────────────────────────────────
  111 +
  112 + @Test
  113 + fun `recordPurchaseConfirmed writes an AP row carrying every event field`() {
  114 + val eventId = UUID.fromString("33333333-3333-4333-8333-333333333333")
  115 + val event = purchaseEvent(
  116 + eventId = eventId,
  117 + orderCode = "PO-99",
  118 + partnerCode = "SUP-PAPER",
  119 + currency = "EUR",
  120 + total = "789.00",
  121 + occurredAt = Instant.parse("2026-04-08T08:00:00Z"),
  122 + )
  123 + val saved = slot<JournalEntry>()
  124 + every { entries.save(capture(saved)) } answers { saved.captured }
  125 +
  126 + val result = service.recordPurchaseConfirmed(event)
  127 +
  128 + assertThat(result).isNotNull()
  129 + with(saved.captured) {
  130 + assertThat(code).isEqualTo(eventId.toString())
  131 + assertThat(type).isEqualTo(JournalEntryType.AP)
  132 + assertThat(orderCode).isEqualTo("PO-99")
  133 + assertThat(partnerCode).isEqualTo("SUP-PAPER")
  134 + assertThat(amount).isEqualTo(BigDecimal("789.00"))
  135 + assertThat(currencyCode).isEqualTo("EUR")
  136 + assertThat(postedAt).isEqualTo(Instant.parse("2026-04-08T08:00:00Z"))
  137 + }
  138 + }
  139 +
  140 + @Test
  141 + fun `recordPurchaseConfirmed is idempotent on duplicate eventId`() {
  142 + val eventId = UUID.fromString("44444444-4444-4444-8444-444444444444")
  143 + every { entries.existsByCode(eventId.toString()) } returns true
  144 +
  145 + val result = service.recordPurchaseConfirmed(purchaseEvent(eventId = eventId))
  146 +
  147 + assertThat(result).isNull()
  148 + verify(exactly = 0) { entries.save(any<JournalEntry>()) }
  149 + }
  150 +
  151 + // ─── confirm event id IS the row code (the dedup contract) ───────
  152 +
  153 + @Test
  154 + fun `event id is used as the entry code (the dedup contract for at-least-once delivery)`() {
  155 + val saleId = UUID.randomUUID()
  156 + val purchaseId = UUID.randomUUID()
  157 + val saved = mutableListOf<JournalEntry>()
  158 + every { entries.save(capture(saved)) } answers { firstArg() }
  159 +
  160 + service.recordSalesConfirmed(salesEvent(eventId = saleId))
  161 + service.recordPurchaseConfirmed(purchaseEvent(eventId = purchaseId))
  162 +
  163 + assertThat(saved[0].code).isEqualTo(saleId.toString())
  164 + assertThat(saved[1].code).isEqualTo(purchaseId.toString())
  165 + }
  166 +
  167 + // ─── compile-time guard: events still implement DomainEvent ──────
  168 +
  169 + @Test
  170 + fun `order events are recognised as DomainEvent (compile-time contract)`() {
  171 + // If api.v1 ever drops the DomainEvent supertype from these
  172 + // classes, this test fails to compile — that is the assertion.
  173 + val s: DomainEvent = salesEvent()
  174 + val p: DomainEvent = purchaseEvent()
  175 + assertThat(s.aggregateType).isEqualTo("orders_sales.SalesOrder")
  176 + assertThat(p.aggregateType).isEqualTo("orders_purchase.PurchaseOrder")
  177 + }
  178 +}
pbc/pbc-finance/src/test/kotlin/org/vibeerp/pbc/finance/event/OrderEventSubscribersTest.kt 0 → 100644
  1 +package org.vibeerp.pbc.finance.event
  2 +
  3 +import io.mockk.every
  4 +import io.mockk.mockk
  5 +import io.mockk.slot
  6 +import io.mockk.verify
  7 +import org.junit.jupiter.api.Test
  8 +import org.vibeerp.api.v1.event.EventBus
  9 +import org.vibeerp.api.v1.event.EventListener
  10 +import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent
  11 +import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent
  12 +import org.vibeerp.pbc.finance.application.JournalEntryService
  13 +
  14 +class OrderEventSubscribersTest {
  15 +
  16 + @Test
  17 + fun `subscribe registers exactly one typed listener for each of the two events`() {
  18 + val eventBus = mockk<EventBus>(relaxed = true)
  19 + val journal = mockk<JournalEntryService>(relaxed = true)
  20 +
  21 + val subscribers = OrderEventSubscribers(eventBus, journal)
  22 + subscribers.subscribe()
  23 +
  24 + verify(exactly = 1) { eventBus.subscribe(SalesOrderConfirmedEvent::class.java, any<EventListener<SalesOrderConfirmedEvent>>()) }
  25 + verify(exactly = 1) { eventBus.subscribe(PurchaseOrderConfirmedEvent::class.java, any<EventListener<PurchaseOrderConfirmedEvent>>()) }
  26 + }
  27 +
  28 + @Test
  29 + fun `the registered sales listener forwards the event to recordSalesConfirmed`() {
  30 + val eventBus = mockk<EventBus>(relaxed = true)
  31 + val journal = mockk<JournalEntryService>(relaxed = true)
  32 + val captured = slot<EventListener<SalesOrderConfirmedEvent>>()
  33 + every { eventBus.subscribe(SalesOrderConfirmedEvent::class.java, capture(captured)) } answers {
  34 + mockk(relaxed = true)
  35 + }
  36 +
  37 + OrderEventSubscribers(eventBus, journal).subscribe()
  38 +
  39 + val event = SalesOrderConfirmedEvent(
  40 + orderCode = "SO-1",
  41 + partnerCode = "CUST-1",
  42 + currencyCode = "USD",
  43 + totalAmount = java.math.BigDecimal("10.00"),
  44 + )
  45 + captured.captured.handle(event)
  46 +
  47 + verify(exactly = 1) { journal.recordSalesConfirmed(event) }
  48 + }
  49 +
  50 + @Test
  51 + fun `the registered purchase listener forwards the event to recordPurchaseConfirmed`() {
  52 + val eventBus = mockk<EventBus>(relaxed = true)
  53 + val journal = mockk<JournalEntryService>(relaxed = true)
  54 + val captured = slot<EventListener<PurchaseOrderConfirmedEvent>>()
  55 + every { eventBus.subscribe(PurchaseOrderConfirmedEvent::class.java, capture(captured)) } answers {
  56 + mockk(relaxed = true)
  57 + }
  58 +
  59 + OrderEventSubscribers(eventBus, journal).subscribe()
  60 +
  61 + val event = PurchaseOrderConfirmedEvent(
  62 + orderCode = "PO-1",
  63 + partnerCode = "SUP-1",
  64 + currencyCode = "USD",
  65 + totalAmount = java.math.BigDecimal("10.00"),
  66 + )
  67 + captured.captured.handle(event)
  68 +
  69 + verify(exactly = 1) { journal.recordPurchaseConfirmed(event) }
  70 + }
  71 +}
settings.gradle.kts
@@ -61,6 +61,9 @@ project(&quot;:pbc:pbc-orders-sales&quot;).projectDir = file(&quot;pbc/pbc-orders-sales&quot;) @@ -61,6 +61,9 @@ project(&quot;:pbc:pbc-orders-sales&quot;).projectDir = file(&quot;pbc/pbc-orders-sales&quot;)
61 include(":pbc:pbc-orders-purchase") 61 include(":pbc:pbc-orders-purchase")
62 project(":pbc:pbc-orders-purchase").projectDir = file("pbc/pbc-orders-purchase") 62 project(":pbc:pbc-orders-purchase").projectDir = file("pbc/pbc-orders-purchase")
63 63
  64 +include(":pbc:pbc-finance")
  65 +project(":pbc:pbc-finance").projectDir = file("pbc/pbc-finance")
  66 +
64 // ─── Reference customer plug-in (NOT loaded by default) ───────────── 67 // ─── Reference customer plug-in (NOT loaded by default) ─────────────
65 include(":reference-customer:plugin-printing-shop") 68 include(":reference-customer:plugin-printing-shop")
66 project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop") 69 project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop")