Commit bf090c2e675fb7b69c22502483be04998d18d030
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.
Showing
16 changed files
with
843 additions
and
18 deletions
CLAUDE.md
| ... | ... | @@ -95,11 +95,11 @@ plugins (incl. ref) depend on: api/api-v1 only |
| 95 | 95 | |
| 96 | 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 | 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 | 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 | 104 | - **Package root** is `org.vibeerp`. |
| 105 | 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 | 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 | 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 | 20 | | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | |
| 21 | 21 | | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. | |
| 22 | 22 | |
| 23 | 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 | 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 | 86 | | P5.6 | `pbc-orders-purchase` — POs + receive flow (RFQs deferred) | ✅ DONE — `2861656` | |
| 87 | 87 | | P5.7 | `pbc-production` — work orders, routings, operations | 🔜 Pending | |
| 88 | 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 | 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 | 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 | 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 | 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 | 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 | 222 | pbc/pbc-orders-purchase PurchaseOrder + PurchaseOrderLine + cross-PBC PurchaseOrdersApi facade |
| 223 | 223 | (the buying-side mirror; receives via InventoryApi.recordMovement |
| 224 | 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 | 230 | reference-customer/plugin-printing-shop |
| 227 | 231 | Reference plug-in: own DB schema (plate, ink_recipe), |
| ... | ... | @@ -230,7 +234,7 @@ reference-customer/plugin-printing-shop |
| 230 | 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 | 239 | ## Where to look next |
| 236 | 240 | ... | ... |
README.md
| ... | ... | @@ -77,7 +77,7 @@ vibe-erp/ |
| 77 | 77 | ## Building |
| 78 | 78 | |
| 79 | 79 | ```bash |
| 80 | -# Build everything (compiles 16 modules, runs 192 unit tests) | |
| 80 | +# Build everything (compiles 17 modules, runs 201 unit tests) | |
| 81 | 81 | ./gradlew build |
| 82 | 82 | |
| 83 | 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 | 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 | 102 | | Cross-cutting services live | 9 | |
| 103 | 103 | | Plug-ins serving HTTP | 1 (reference printing-shop) | |
| 104 | 104 | | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending | | ... | ... |
distribution/build.gradle.kts
| ... | ... | @@ -32,6 +32,7 @@ dependencies { |
| 32 | 32 | implementation(project(":pbc:pbc-inventory")) |
| 33 | 33 | implementation(project(":pbc:pbc-orders-sales")) |
| 34 | 34 | implementation(project(":pbc:pbc-orders-purchase")) |
| 35 | + implementation(project(":pbc:pbc-finance")) | |
| 35 | 36 | |
| 36 | 37 | implementation(libs.spring.boot.starter) |
| 37 | 38 | implementation(libs.spring.boot.starter.web) | ... | ... |
distribution/src/main/resources/db/changelog/master.xml
| ... | ... | @@ -20,4 +20,5 @@ |
| 20 | 20 | <include file="classpath:db/changelog/pbc-inventory/002-inventory-movement-ledger.xml"/> |
| 21 | 21 | <include file="classpath:db/changelog/pbc-orders-sales/001-orders-sales-init.xml"/> |
| 22 | 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 | 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(":pbc:pbc-orders-sales").projectDir = file("pbc/pbc-orders-sales") |
| 61 | 61 | include(":pbc:pbc-orders-purchase") |
| 62 | 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 | 67 | // ─── Reference customer plug-in (NOT loaded by default) ───────────── |
| 65 | 68 | include(":reference-customer:plugin-printing-shop") |
| 66 | 69 | project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop") | ... | ... |
-
mentioned in commit 0e9736c9