diff --git a/CLAUDE.md b/CLAUDE.md index d6411f1..546853a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,11 +95,11 @@ plugins (incl. ref) depend on: api/api-v1 only **Foundation complete; first business surface in place.** As of the latest commit: -- **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`. -- **192 unit tests across 16 modules**, all green. `./gradlew build` is the canonical full build. +- **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`. +- **201 unit tests across 17 modules**, all green. `./gradlew build` is the canonical full build. - **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. -- **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. -- **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. +- **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. +- **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. - **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. - **Package root** is `org.vibeerp`. - **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. diff --git a/PROGRESS.md b/PROGRESS.md index 633e937..321a0dc 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,19 +10,19 @@ | | | |---|---| -| **Latest version** | v0.15 (post-event-driven cross-PBC integration) | -| **Latest commit** | `b1d433e feat(events): wire SalesOrderService + PurchaseOrderService onto the event bus` | +| **Latest version** | v0.16 (post-pbc-finance: first cross-PBC event consumer) | +| **Latest commit** | `` | | **Repo** | https://github.com/reporkey/vibe-erp | -| **Modules** | 16 | -| **Unit tests** | 192, all green | -| **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. | -| **Real PBCs implemented** | 6 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`) | +| **Modules** | 17 | +| **Unit tests** | 201, all green | +| **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. | +| **Real PBCs implemented** | 7 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`) | | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. | ## Current stage -**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. +**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. 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. @@ -86,7 +86,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **22 a | P5.6 | `pbc-orders-purchase` — POs + receive flow (RFQs deferred) | ✅ DONE — `2861656` | | P5.7 | `pbc-production` — work orders, routings, operations | 🔜 Pending | | P5.8 | `pbc-quality` — inspection plans, results, holds | 🔜 Pending | -| P5.9 | `pbc-finance` — GL, journal entries, AR/AP minimal | 🔜 Pending | +| P5.9 | `pbc-finance` — GL, journal entries, AR/AP minimal | ✅ Partial — minimal AR/AP journal entries driven by events; full GL pending | ### Phase 6 — Web SPA (React + TS) @@ -129,7 +129,7 @@ These are the cross-cutting platform services already wired into the running fra | **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. | | **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_.properties` resolves before the host's `messages_.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. | | **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. | -| **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. | +| **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. | ## What the reference plug-in proves end-to-end @@ -222,6 +222,10 @@ pbc/pbc-orders-sales SalesOrder + SalesOrderLine + cross-PBC SalesOrder pbc/pbc-orders-purchase PurchaseOrder + PurchaseOrderLine + cross-PBC PurchaseOrdersApi facade (the buying-side mirror; receives via InventoryApi.recordMovement with positive PURCHASE_RECEIPT deltas) +pbc/pbc-finance JournalEntry + read-only controller; first CONSUMER PBC. + Subscribes to SalesOrderConfirmedEvent + PurchaseOrderConfirmedEvent + via api.v1 EventBus.subscribe(eventType, listener) and writes + idempotent AR/AP rows. No outbound facade. reference-customer/plugin-printing-shop Reference plug-in: own DB schema (plate, ink_recipe), @@ -230,7 +234,7 @@ reference-customer/plugin-printing-shop distribution Bootable Spring Boot fat-jar assembly ``` -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. +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. ## Where to look next diff --git a/README.md b/README.md index 4fff8f5..ab7dc48 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ vibe-erp/ ## Building ```bash -# Build everything (compiles 16 modules, runs 192 unit tests) +# Build everything (compiles 17 modules, runs 201 unit tests) ./gradlew build # 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 | | | |---|---| -| Modules | 16 | -| Unit tests | 192, all green | -| Real PBCs | 6 of 10 | +| Modules | 17 | +| Unit tests | 201, all green | +| Real PBCs | 7 of 10 | | Cross-cutting services live | 9 | | Plug-ins serving HTTP | 1 (reference printing-shop) | | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending | diff --git a/distribution/build.gradle.kts b/distribution/build.gradle.kts index a2b721a..d205b27 100644 --- a/distribution/build.gradle.kts +++ b/distribution/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { implementation(project(":pbc:pbc-inventory")) implementation(project(":pbc:pbc-orders-sales")) implementation(project(":pbc:pbc-orders-purchase")) + implementation(project(":pbc:pbc-finance")) implementation(libs.spring.boot.starter) implementation(libs.spring.boot.starter.web) diff --git a/distribution/src/main/resources/db/changelog/master.xml b/distribution/src/main/resources/db/changelog/master.xml index cea7e1b..a3974de 100644 --- a/distribution/src/main/resources/db/changelog/master.xml +++ b/distribution/src/main/resources/db/changelog/master.xml @@ -20,4 +20,5 @@ + diff --git a/distribution/src/main/resources/db/changelog/pbc-finance/001-finance-init.xml b/distribution/src/main/resources/db/changelog/pbc-finance/001-finance-init.xml new file mode 100644 index 0000000..f2608a4 --- /dev/null +++ b/distribution/src/main/resources/db/changelog/pbc-finance/001-finance-init.xml @@ -0,0 +1,67 @@ + + + + + + + Create finance__journal_entry table + + CREATE TABLE finance__journal_entry ( + id uuid PRIMARY KEY, + code varchar(64) NOT NULL, + type varchar(8) NOT NULL, + partner_code varchar(64) NOT NULL, + order_code varchar(64) NOT NULL, + amount numeric(18,4) NOT NULL, + currency_code varchar(3) NOT NULL, + posted_at timestamptz NOT NULL, + ext jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL, + created_by varchar(128) NOT NULL, + updated_at timestamptz NOT NULL, + updated_by varchar(128) NOT NULL, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT finance__journal_entry_type_check CHECK (type IN ('AR', 'AP')) + ); + CREATE UNIQUE INDEX finance__journal_entry_code_uk + ON finance__journal_entry (code); + CREATE INDEX finance__journal_entry_partner_idx + ON finance__journal_entry (partner_code); + CREATE INDEX finance__journal_entry_order_idx + ON finance__journal_entry (order_code); + CREATE INDEX finance__journal_entry_type_idx + ON finance__journal_entry (type); + CREATE INDEX finance__journal_entry_posted_idx + ON finance__journal_entry (posted_at); + CREATE INDEX finance__journal_entry_ext_gin + ON finance__journal_entry USING GIN (ext jsonb_path_ops); + + + DROP TABLE finance__journal_entry; + + + + diff --git a/pbc/pbc-finance/build.gradle.kts b/pbc/pbc-finance/build.gradle.kts new file mode 100644 index 0000000..a6df980 --- /dev/null +++ b/pbc/pbc-finance/build.gradle.kts @@ -0,0 +1,57 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) + alias(libs.plugins.kotlin.jpa) + alias(libs.plugins.spring.dependency.management) +} + +description = "vibe_erp pbc-finance — minimal AR/AP journal entries driven by domain events from sales and purchase orders. INTERNAL Packaged Business Capability." + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +kotlin { + jvmToolchain(21) + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") + } +} + +allOpen { + annotation("jakarta.persistence.Entity") + annotation("jakarta.persistence.MappedSuperclass") + annotation("jakarta.persistence.Embeddable") +} + +// pbc-finance is the framework's first CONSUMER PBC: it doesn't expose +// any cross-PBC service of its own (yet) but instead reacts to events +// published by other PBCs through api.v1.event.orders.*. Like every +// other PBC, it must NOT depend on another pbc-* — the dependency rule +// is enforced by the root build. Inbound events arrive via the +// platform-events EventBus interface defined in api.v1. +dependencies { + api(project(":api:api-v1")) + implementation(project(":platform:platform-persistence")) + implementation(project(":platform:platform-security")) + + implementation(libs.kotlin.stdlib) + implementation(libs.kotlin.reflect) + + implementation(libs.spring.boot.starter) + implementation(libs.spring.boot.starter.web) + implementation(libs.spring.boot.starter.data.jpa) + implementation(libs.spring.boot.starter.validation) + implementation(libs.jackson.module.kotlin) + + testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.junit.jupiter) + testImplementation(libs.assertk) + testImplementation(libs.mockk) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/application/JournalEntryService.kt b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/application/JournalEntryService.kt new file mode 100644 index 0000000..3dbb216 --- /dev/null +++ b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/application/JournalEntryService.kt @@ -0,0 +1,132 @@ +package org.vibeerp.pbc.finance.application + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent +import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent +import org.vibeerp.pbc.finance.domain.JournalEntry +import org.vibeerp.pbc.finance.domain.JournalEntryType +import org.vibeerp.pbc.finance.infrastructure.JournalEntryJpaRepository +import java.util.UUID + +/** + * Application service for [JournalEntry] writes triggered by + * cross-PBC domain events. + * + * **Why these methods take the event as the input rather than a + * separate command DTO:** the events ARE the command. The whole + * point of cross-PBC integration is that the producer's event + * carries everything the consumer needs — there is no separate + * REST request, and inventing a parallel command type would just + * be ceremony. The api.v1 events are stable contracts (semver- + * governed) so binding pbc-finance to them carries the same upgrade + * cost as binding to any other api.v1 surface. + * + * **Transaction propagation: REQUIRED.** Per the EventListener KDoc, + * a listener must NOT assume the publisher's transaction is still + * open. Today the in-process EventBusImpl delivers synchronously + * inside the publisher's transaction, so REQUIRED simply joins it + * and the journal entry shares the same atomic boundary as the + * order's status change. If a future async bus delivers events from + * a worker thread without an active transaction, REQUIRED creates a + * fresh one — the same code keeps working under both delivery + * models. Using REQUIRES_NEW would always create a fresh transaction + * even today, breaking the desirable atomicity-with-the-publisher + * for the synchronous case. REQUIRED is the right default. + * + * **Idempotency.** Each method short-circuits if a row already + * exists for the event's id (the `code` column). The framework's + * outbox could deliver an event twice (a Kafka bridge retry, an + * outbox replay after crash) and the consumer must remain correct. + */ +@Service +@Transactional(propagation = Propagation.REQUIRED) +class JournalEntryService( + private val entries: JournalEntryJpaRepository, +) { + + private val log = LoggerFactory.getLogger(JournalEntryService::class.java) + + /** + * React to a `SalesOrderConfirmedEvent` by writing an AR row. + * + * The customer now owes us the order total — that's the entire + * accounting story for the v0.16 minimal finance PBC. Real + * revenue recognition (which happens at SHIP time, not CONFIRM + * time, for most accounting standards) lives in the future + * P5.9 build. + */ + fun recordSalesConfirmed(event: SalesOrderConfirmedEvent): JournalEntry? { + val code = event.eventId.value.toString() + if (entries.existsByCode(code)) { + log.debug( + "[finance] dropping duplicate SalesOrderConfirmedEvent eventId={} orderCode={}", + code, event.orderCode, + ) + return null + } + log.info( + "[finance] AR ← SalesOrderConfirmedEvent orderCode={} partnerCode={} amount={} {}", + event.orderCode, event.partnerCode, event.totalAmount, event.currencyCode, + ) + return entries.save( + JournalEntry( + code = code, + type = JournalEntryType.AR, + partnerCode = event.partnerCode, + orderCode = event.orderCode, + amount = event.totalAmount, + currencyCode = event.currencyCode, + postedAt = event.occurredAt, + ), + ) + } + + /** + * React to a `PurchaseOrderConfirmedEvent` by writing an AP row. + * + * Mirror of [recordSalesConfirmed] for the buying side. The + * supplier is now owed the order total. Real cash-flow timing + * (and the actual payable becoming payable on receipt or + * invoice match) lives in the future P5.9 build. + */ + fun recordPurchaseConfirmed(event: PurchaseOrderConfirmedEvent): JournalEntry? { + val code = event.eventId.value.toString() + if (entries.existsByCode(code)) { + log.debug( + "[finance] dropping duplicate PurchaseOrderConfirmedEvent eventId={} orderCode={}", + code, event.orderCode, + ) + return null + } + log.info( + "[finance] AP ← PurchaseOrderConfirmedEvent orderCode={} partnerCode={} amount={} {}", + event.orderCode, event.partnerCode, event.totalAmount, event.currencyCode, + ) + return entries.save( + JournalEntry( + code = code, + type = JournalEntryType.AP, + partnerCode = event.partnerCode, + orderCode = event.orderCode, + amount = event.totalAmount, + currencyCode = event.currencyCode, + postedAt = event.occurredAt, + ), + ) + } + + @Transactional(readOnly = true) + fun list(): List = entries.findAll() + + @Transactional(readOnly = true) + fun findById(id: UUID): JournalEntry? = entries.findById(id).orElse(null) + + @Transactional(readOnly = true) + fun findByOrderCode(orderCode: String): List = entries.findByOrderCode(orderCode) + + @Transactional(readOnly = true) + fun findByType(type: JournalEntryType): List = entries.findByType(type) +} diff --git a/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/JournalEntry.kt b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/JournalEntry.kt new file mode 100644 index 0000000..edaa36b --- /dev/null +++ b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/JournalEntry.kt @@ -0,0 +1,105 @@ +package org.vibeerp.pbc.finance.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Table +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.type.SqlTypes +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity +import java.math.BigDecimal +import java.time.Instant + +/** + * A single accounting row produced by pbc-finance in reaction to a + * domain event from another PBC. + * + * **What this is — and what it deliberately is NOT.** + * - It IS a minimal journal-entry table sufficient to demonstrate + * that **a NEW PBC can subscribe to events from existing PBCs + * without importing them**. That is the entire purpose of this + * chunk: validate the consumer side of the cross-PBC event seam + * that landed in commit `67406e8`. + * - It is NOT a real general ledger. There are no debit/credit + * legs, no chart of accounts, no period close, no double-entry + * invariant enforcement. P5.9 is the chunk that grows this into + * a real finance PBC; this row exists so that "confirmed sales + * order produces a finance side-effect" is observable end-to-end. + * + * **Idempotency by event id.** The [code] column carries the + * originating event's UUID, with a unique index. If the same event + * is delivered twice (e.g. an outbox replay after a crash, or a + * future Kafka bridge retry) the second insert is a no-op. This is + * how we make event subscribers safe under at-least-once delivery + * without growing a separate dedup table. + * + * **Cross-PBC reference policy.** [partnerCode] and [orderCode] are + * stored as strings, not foreign keys. The PBC that owns the partner + * (`pbc-partners`) and the PBC that owns the order (`pbc-orders-sales` + * or `pbc-orders-purchase`) are separate bounded contexts; a database + * FK across PBCs would couple their schemas at the storage level + * and defeat the bounded-context rule (CLAUDE.md guardrail #9). + */ +@Entity +@Table(name = "finance__journal_entry") +class JournalEntry( + code: String, + type: JournalEntryType, + partnerCode: String, + orderCode: String, + amount: BigDecimal, + currencyCode: String, + postedAt: Instant, +) : AuditedJpaEntity() { + + @Column(name = "code", nullable = false, length = 64) + var code: String = code + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 8) + var type: JournalEntryType = type + + @Column(name = "partner_code", nullable = false, length = 64) + var partnerCode: String = partnerCode + + @Column(name = "order_code", nullable = false, length = 64) + var orderCode: String = orderCode + + @Column(name = "amount", nullable = false, precision = 18, scale = 4) + var amount: BigDecimal = amount + + @Column(name = "currency_code", nullable = false, length = 3) + var currencyCode: String = currencyCode + + @Column(name = "posted_at", nullable = false) + var postedAt: Instant = postedAt + + @Column(name = "ext", nullable = false, columnDefinition = "jsonb") + @JdbcTypeCode(SqlTypes.JSON) + var ext: String = "{}" + + override fun toString(): String = + "JournalEntry(id=$id, code='$code', type=$type, partner='$partnerCode', order='$orderCode', amount=$amount $currencyCode)" +} + +/** + * The accounting direction the entry represents. + * + * - **AR** (Accounts Receivable) — money the company is owed. + * Produced by `SalesOrderConfirmedEvent`: when we confirm a sales + * order to a customer, the customer now owes us the order total. + * - **AP** (Accounts Payable) — money the company owes someone. + * Produced by `PurchaseOrderConfirmedEvent`: when we confirm a PO + * to a supplier, we now owe the supplier the order total. + * + * Receipt and shipment events do NOT (yet) generate journal entries. + * That belongs to the real P5.9 finance build, where shipping + * recognises revenue and receiving recognises inventory cost — both + * of which require a real chart of accounts. The minimal v0.16 + * version stops at "an order was confirmed → an AR/AP row exists". + */ +enum class JournalEntryType { + AR, + AP, +} diff --git a/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/event/OrderEventSubscribers.kt b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/event/OrderEventSubscribers.kt new file mode 100644 index 0000000..a7e92e2 --- /dev/null +++ b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/event/OrderEventSubscribers.kt @@ -0,0 +1,71 @@ +package org.vibeerp.pbc.finance.event + +import jakarta.annotation.PostConstruct +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.vibeerp.api.v1.event.EventBus +import org.vibeerp.api.v1.event.EventListener +import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent +import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent +import org.vibeerp.pbc.finance.application.JournalEntryService + +/** + * Subscribes pbc-finance to the two cross-PBC events it cares about + * and forwards each delivery to [JournalEntryService]. + * + * **The framework's first cross-PBC event subscriber.** The + * EventBus seam has existed since P1.7, but the only existing + * subscriber (`EventAuditLogSubscriber`) lives in `platform-events` + * and uses the wildcard `subscribeToAll` helper that's deliberately + * not exposed to PBCs. This component proves the **public, + * api.v1-only** subscribe path: a brand-new PBC, with no source + * dependency on the producing PBCs, picks up exactly the typed + * events it cares about by their event class. + * + * **Why one bean with two subscriptions, instead of two beans:** + * - Both subscriptions share the same `JournalEntryService` and the + * same registration moment. Splitting them into two `@Component` + * classes would be cargo-culted symmetry without ergonomic gain. + * - When more events join (shipped, received, cancelled), they + * accumulate as additional `subscribe(...)` calls in the same + * `@PostConstruct` block — one place to read, one place to grep. + * + * **Why `@PostConstruct` instead of an `ApplicationListener`-style + * registration:** the `EventBus` interface is the public api.v1 + * surface; using a Spring-internal `ApplicationEventPublisher` would + * defeat the point of the chunk (proving that pbc-finance can wire + * itself through the framework's stable event seam, not Spring's). + * + * **Subscription handles are intentionally NOT stored.** Per the + * `EventBus.Subscription` KDoc, the platform tears down a stopped + * plug-in's listeners automatically. pbc-finance is a built-in PBC + * that runs for the lifetime of the process, so there's no + * deregistration moment we need to handle. If a future feature + * (feature flags, hot-reload) needs runtime deregistration, the + * obvious change is to keep the [EventBus.Subscription] handles in + * a `MutableList` and call `close()` from a `@PreDestroy`. + */ +@Component +class OrderEventSubscribers( + private val eventBus: EventBus, + private val journal: JournalEntryService, +) { + + private val log = LoggerFactory.getLogger(OrderEventSubscribers::class.java) + + @PostConstruct + fun subscribe() { + eventBus.subscribe( + SalesOrderConfirmedEvent::class.java, + EventListener { event -> journal.recordSalesConfirmed(event) }, + ) + eventBus.subscribe( + PurchaseOrderConfirmedEvent::class.java, + EventListener { event -> journal.recordPurchaseConfirmed(event) }, + ) + log.info( + "pbc-finance subscribed to SalesOrderConfirmedEvent and PurchaseOrderConfirmedEvent " + + "via EventBus.subscribe (typed-class overload)", + ) + } +} diff --git a/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/http/JournalEntryController.kt b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/http/JournalEntryController.kt new file mode 100644 index 0000000..f7540f7 --- /dev/null +++ b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/http/JournalEntryController.kt @@ -0,0 +1,85 @@ +package org.vibeerp.pbc.finance.http + +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import org.vibeerp.pbc.finance.application.JournalEntryService +import org.vibeerp.pbc.finance.domain.JournalEntry +import org.vibeerp.pbc.finance.domain.JournalEntryType +import org.vibeerp.platform.security.authz.RequirePermission +import java.math.BigDecimal +import java.time.Instant +import java.util.UUID + +/** + * Read-only REST API for [JournalEntry]. + * + * **Read-only on purpose.** Journal entries in pbc-finance are + * derived state — they only ever come into existence as the + * downstream effect of a domain event from another PBC. There is no + * `POST /api/v1/finance/journal-entries` because creating one + * by hand would defeat the entire seam being demonstrated. Future + * adjustments and reversals will land as their own command verbs + * (`adjust`, `reverse`) when the real P5.9 finance build needs + * them, not as a generic create endpoint. + * + * **Why under `/api/v1/finance`** instead of `/api/v1/finance/` + * (with a trailing slash) or `/api/v1/finance/ledger`: every other + * PBC follows `/api/v1//` and finance + * follows the same convention. The "ledger" subpath would be + * misleading because v0.16 has no real ledger — only journal + * entries. + */ +@RestController +@RequestMapping("/api/v1/finance/journal-entries") +class JournalEntryController( + private val journalEntryService: JournalEntryService, +) { + + @GetMapping + @RequirePermission("finance.journal.read") + fun list( + @RequestParam(required = false) orderCode: String?, + @RequestParam(required = false) type: JournalEntryType?, + ): List { + val rows = when { + orderCode != null -> journalEntryService.findByOrderCode(orderCode) + type != null -> journalEntryService.findByType(type) + else -> journalEntryService.list() + } + return rows.map { it.toResponse() } + } + + @GetMapping("/{id}") + @RequirePermission("finance.journal.read") + fun get(@PathVariable id: UUID): ResponseEntity { + val entry = journalEntryService.findById(id) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(entry.toResponse()) + } +} + +data class JournalEntryResponse( + val id: UUID, + val code: String, + val type: JournalEntryType, + val partnerCode: String, + val orderCode: String, + val amount: BigDecimal, + val currencyCode: String, + val postedAt: Instant, +) + +private fun JournalEntry.toResponse(): JournalEntryResponse = + JournalEntryResponse( + id = id, + code = code, + type = type, + partnerCode = partnerCode, + orderCode = orderCode, + amount = amount, + currencyCode = currencyCode, + postedAt = postedAt, + ) diff --git a/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/infrastructure/JournalEntryJpaRepository.kt b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/infrastructure/JournalEntryJpaRepository.kt new file mode 100644 index 0000000..01f7924 --- /dev/null +++ b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/infrastructure/JournalEntryJpaRepository.kt @@ -0,0 +1,25 @@ +package org.vibeerp.pbc.finance.infrastructure + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import org.vibeerp.pbc.finance.domain.JournalEntry +import org.vibeerp.pbc.finance.domain.JournalEntryType +import java.util.UUID + +/** + * Spring Data JPA repository for [JournalEntry]. + * + * The `existsByCode` query is the dedup hook used by + * [org.vibeerp.pbc.finance.application.JournalEntryService] to make + * event delivery idempotent — a second copy of the same event is a + * no-op. The unique constraint on `finance__journal_entry.code` + * (defined in the Liquibase changelog) is the durability anchor; + * the existsByCode check is the clean-error path. + */ +@Repository +interface JournalEntryJpaRepository : JpaRepository { + fun existsByCode(code: String): Boolean + fun findByCode(code: String): JournalEntry? + fun findByOrderCode(orderCode: String): List + fun findByType(type: JournalEntryType): List +} diff --git a/pbc/pbc-finance/src/main/resources/META-INF/vibe-erp/metadata/finance.yml b/pbc/pbc-finance/src/main/resources/META-INF/vibe-erp/metadata/finance.yml new file mode 100644 index 0000000..6e9b5e0 --- /dev/null +++ b/pbc/pbc-finance/src/main/resources/META-INF/vibe-erp/metadata/finance.yml @@ -0,0 +1,25 @@ +# pbc-finance metadata. +# +# Loaded at boot by MetadataLoader, tagged source='core'. The minimal +# v0.16 build only carries the JournalEntry entity, the read permission, +# and a navigation menu entry. No write permissions exist because there +# is no write endpoint — entries appear automatically in reaction to +# domain events from other PBCs (SalesOrderConfirmed → AR row, +# PurchaseOrderConfirmed → AP row). + +entities: + - name: JournalEntry + pbc: finance + table: finance__journal_entry + description: A single AR/AP journal entry derived from a sales- or purchase-order confirmation event + +permissions: + - key: finance.journal.read + description: Read journal entries + +menus: + - path: /finance/journal-entries + label: Journal entries + icon: book-open + section: Finance + order: 700 diff --git a/pbc/pbc-finance/src/test/kotlin/org/vibeerp/pbc/finance/application/JournalEntryServiceTest.kt b/pbc/pbc-finance/src/test/kotlin/org/vibeerp/pbc/finance/application/JournalEntryServiceTest.kt new file mode 100644 index 0000000..2f33efa --- /dev/null +++ b/pbc/pbc-finance/src/test/kotlin/org/vibeerp/pbc/finance/application/JournalEntryServiceTest.kt @@ -0,0 +1,178 @@ +package org.vibeerp.pbc.finance.application + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.event.DomainEvent +import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent +import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent +import org.vibeerp.pbc.finance.domain.JournalEntry +import org.vibeerp.pbc.finance.domain.JournalEntryType +import org.vibeerp.pbc.finance.infrastructure.JournalEntryJpaRepository +import java.math.BigDecimal +import java.time.Instant +import java.util.UUID + +class JournalEntryServiceTest { + + private lateinit var entries: JournalEntryJpaRepository + private lateinit var service: JournalEntryService + + @BeforeEach + fun setUp() { + entries = mockk() + every { entries.existsByCode(any()) } returns false + every { entries.save(any()) } answers { firstArg() } + service = JournalEntryService(entries) + } + + private fun salesEvent( + eventId: UUID = UUID.randomUUID(), + orderCode: String = "SO-1", + partnerCode: String = "CUST-1", + currency: String = "USD", + total: String = "100.00", + occurredAt: Instant = Instant.parse("2026-04-08T10:00:00Z"), + ) = SalesOrderConfirmedEvent( + orderCode = orderCode, + partnerCode = partnerCode, + currencyCode = currency, + totalAmount = BigDecimal(total), + eventId = Id(eventId), + occurredAt = occurredAt, + ) + + private fun purchaseEvent( + eventId: UUID = UUID.randomUUID(), + orderCode: String = "PO-1", + partnerCode: String = "SUP-1", + currency: String = "USD", + total: String = "550.00", + occurredAt: Instant = Instant.parse("2026-04-08T11:00:00Z"), + ) = PurchaseOrderConfirmedEvent( + orderCode = orderCode, + partnerCode = partnerCode, + currencyCode = currency, + totalAmount = BigDecimal(total), + eventId = Id(eventId), + occurredAt = occurredAt, + ) + + // ─── recordSalesConfirmed ──────────────────────────────────────── + + @Test + fun `recordSalesConfirmed writes an AR row carrying every event field`() { + val eventId = UUID.fromString("11111111-1111-4111-8111-111111111111") + val event = salesEvent( + eventId = eventId, + orderCode = "SO-42", + partnerCode = "CUST-ACME", + currency = "USD", + total = "1234.56", + occurredAt = Instant.parse("2026-04-08T12:34:56Z"), + ) + val saved = slot() + every { entries.save(capture(saved)) } answers { saved.captured } + + val result = service.recordSalesConfirmed(event) + + assertThat(result).isNotNull() + with(saved.captured) { + assertThat(code).isEqualTo(eventId.toString()) + assertThat(type).isEqualTo(JournalEntryType.AR) + assertThat(orderCode).isEqualTo("SO-42") + assertThat(partnerCode).isEqualTo("CUST-ACME") + assertThat(amount).isEqualTo(BigDecimal("1234.56")) + assertThat(currencyCode).isEqualTo("USD") + assertThat(postedAt).isEqualTo(Instant.parse("2026-04-08T12:34:56Z")) + } + } + + @Test + fun `recordSalesConfirmed is idempotent when an entry with the same eventId already exists`() { + val eventId = UUID.fromString("22222222-2222-4222-8222-222222222222") + every { entries.existsByCode(eventId.toString()) } returns true + + val result = service.recordSalesConfirmed(salesEvent(eventId = eventId)) + + assertThat(result).isNull() + verify(exactly = 0) { entries.save(any()) } + } + + // ─── recordPurchaseConfirmed ───────────────────────────────────── + + @Test + fun `recordPurchaseConfirmed writes an AP row carrying every event field`() { + val eventId = UUID.fromString("33333333-3333-4333-8333-333333333333") + val event = purchaseEvent( + eventId = eventId, + orderCode = "PO-99", + partnerCode = "SUP-PAPER", + currency = "EUR", + total = "789.00", + occurredAt = Instant.parse("2026-04-08T08:00:00Z"), + ) + val saved = slot() + every { entries.save(capture(saved)) } answers { saved.captured } + + val result = service.recordPurchaseConfirmed(event) + + assertThat(result).isNotNull() + with(saved.captured) { + assertThat(code).isEqualTo(eventId.toString()) + assertThat(type).isEqualTo(JournalEntryType.AP) + assertThat(orderCode).isEqualTo("PO-99") + assertThat(partnerCode).isEqualTo("SUP-PAPER") + assertThat(amount).isEqualTo(BigDecimal("789.00")) + assertThat(currencyCode).isEqualTo("EUR") + assertThat(postedAt).isEqualTo(Instant.parse("2026-04-08T08:00:00Z")) + } + } + + @Test + fun `recordPurchaseConfirmed is idempotent on duplicate eventId`() { + val eventId = UUID.fromString("44444444-4444-4444-8444-444444444444") + every { entries.existsByCode(eventId.toString()) } returns true + + val result = service.recordPurchaseConfirmed(purchaseEvent(eventId = eventId)) + + assertThat(result).isNull() + verify(exactly = 0) { entries.save(any()) } + } + + // ─── confirm event id IS the row code (the dedup contract) ─────── + + @Test + fun `event id is used as the entry code (the dedup contract for at-least-once delivery)`() { + val saleId = UUID.randomUUID() + val purchaseId = UUID.randomUUID() + val saved = mutableListOf() + every { entries.save(capture(saved)) } answers { firstArg() } + + service.recordSalesConfirmed(salesEvent(eventId = saleId)) + service.recordPurchaseConfirmed(purchaseEvent(eventId = purchaseId)) + + assertThat(saved[0].code).isEqualTo(saleId.toString()) + assertThat(saved[1].code).isEqualTo(purchaseId.toString()) + } + + // ─── compile-time guard: events still implement DomainEvent ────── + + @Test + fun `order events are recognised as DomainEvent (compile-time contract)`() { + // If api.v1 ever drops the DomainEvent supertype from these + // classes, this test fails to compile — that is the assertion. + val s: DomainEvent = salesEvent() + val p: DomainEvent = purchaseEvent() + assertThat(s.aggregateType).isEqualTo("orders_sales.SalesOrder") + assertThat(p.aggregateType).isEqualTo("orders_purchase.PurchaseOrder") + } +} diff --git a/pbc/pbc-finance/src/test/kotlin/org/vibeerp/pbc/finance/event/OrderEventSubscribersTest.kt b/pbc/pbc-finance/src/test/kotlin/org/vibeerp/pbc/finance/event/OrderEventSubscribersTest.kt new file mode 100644 index 0000000..ba7086d --- /dev/null +++ b/pbc/pbc-finance/src/test/kotlin/org/vibeerp/pbc/finance/event/OrderEventSubscribersTest.kt @@ -0,0 +1,71 @@ +package org.vibeerp.pbc.finance.event + +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.vibeerp.api.v1.event.EventBus +import org.vibeerp.api.v1.event.EventListener +import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent +import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent +import org.vibeerp.pbc.finance.application.JournalEntryService + +class OrderEventSubscribersTest { + + @Test + fun `subscribe registers exactly one typed listener for each of the two events`() { + val eventBus = mockk(relaxed = true) + val journal = mockk(relaxed = true) + + val subscribers = OrderEventSubscribers(eventBus, journal) + subscribers.subscribe() + + verify(exactly = 1) { eventBus.subscribe(SalesOrderConfirmedEvent::class.java, any>()) } + verify(exactly = 1) { eventBus.subscribe(PurchaseOrderConfirmedEvent::class.java, any>()) } + } + + @Test + fun `the registered sales listener forwards the event to recordSalesConfirmed`() { + val eventBus = mockk(relaxed = true) + val journal = mockk(relaxed = true) + val captured = slot>() + every { eventBus.subscribe(SalesOrderConfirmedEvent::class.java, capture(captured)) } answers { + mockk(relaxed = true) + } + + OrderEventSubscribers(eventBus, journal).subscribe() + + val event = SalesOrderConfirmedEvent( + orderCode = "SO-1", + partnerCode = "CUST-1", + currencyCode = "USD", + totalAmount = java.math.BigDecimal("10.00"), + ) + captured.captured.handle(event) + + verify(exactly = 1) { journal.recordSalesConfirmed(event) } + } + + @Test + fun `the registered purchase listener forwards the event to recordPurchaseConfirmed`() { + val eventBus = mockk(relaxed = true) + val journal = mockk(relaxed = true) + val captured = slot>() + every { eventBus.subscribe(PurchaseOrderConfirmedEvent::class.java, capture(captured)) } answers { + mockk(relaxed = true) + } + + OrderEventSubscribers(eventBus, journal).subscribe() + + val event = PurchaseOrderConfirmedEvent( + orderCode = "PO-1", + partnerCode = "SUP-1", + currencyCode = "USD", + totalAmount = java.math.BigDecimal("10.00"), + ) + captured.captured.handle(event) + + verify(exactly = 1) { journal.recordPurchaseConfirmed(event) } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 984ff23..2b643ce 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -61,6 +61,9 @@ project(":pbc:pbc-orders-sales").projectDir = file("pbc/pbc-orders-sales") include(":pbc:pbc-orders-purchase") project(":pbc:pbc-orders-purchase").projectDir = file("pbc/pbc-orders-purchase") +include(":pbc:pbc-finance") +project(":pbc:pbc-finance").projectDir = file("pbc/pbc-finance") + // ─── Reference customer plug-in (NOT loaded by default) ───────────── include(":reference-customer:plugin-printing-shop") project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop")