From 0e9736c9205fda276da95d6b4c6dfad6907155c3 Mon Sep 17 00:00:00 2001 From: zichun Date: Wed, 8 Apr 2026 21:15:31 +0800 Subject: [PATCH] feat(pbc-finance): react to ship/receive/cancel — full lifecycle on the journal --- CLAUDE.md | 4 ++-- PROGRESS.md | 10 +++++----- README.md | 4 ++-- distribution/src/main/resources/db/changelog/master.xml | 1 + distribution/src/main/resources/db/changelog/pbc-finance/002-finance-status.xml | 45 +++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/application/JournalEntryService.kt | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/JournalEntry.kt | 42 +++++++++++++++++++++++++++++++++++++++++- pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/event/OrderEventSubscribers.kt | 29 ++++++++++++++++++++++++++++- pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/http/JournalEntryController.kt | 5 +++++ pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/infrastructure/JournalEntryJpaRepository.kt | 20 ++++++++++++++++++++ pbc/pbc-finance/src/test/kotlin/org/vibeerp/pbc/finance/application/JournalEntryServiceTest.kt | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-finance/src/test/kotlin/org/vibeerp/pbc/finance/event/OrderEventSubscribersTest.kt | 40 +++++++++++++++++++++++++++++++++++++++- 12 files changed, 456 insertions(+), 12 deletions(-) create mode 100644 distribution/src/main/resources/db/changelog/pbc-finance/002-finance-status.xml diff --git a/CLAUDE.md b/CLAUDE.md index 546853a..a8d2d97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,10 +96,10 @@ plugins (incl. ref) depend on: api/api-v1 only **Foundation complete; first business surface in place.** As of the latest commit: - **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. +- **213 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. - **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. +- **Event-driven cross-PBC integration is live in BOTH directions and the consumer reacts to the full lifecycle.** 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. **pbc-finance** subscribes to ALL SIX of them via the api.v1 `EventBus.subscribe(eventType, listener)` typed-class overload: confirm events POST AR/AP rows; ship/receive events SETTLE them; cancel events REVERSE them. Each transition is idempotent under at-least-once delivery — re-applying the same destination status is a no-op. Cancel-from-DRAFT is a clean no-op because no `*ConfirmedEvent` was ever published. pbc-finance has no source dependency on pbc-orders-*; it reaches them only through events. - **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 10f6502..7ff78f9 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,19 +10,19 @@ | | | |---|---| -| **Latest version** | v0.16 (post-pbc-finance: first cross-PBC event consumer) | -| **Latest commit** | `bf090c2 feat(pbc): pbc-finance — first cross-PBC event consumer (minimal AR/AP)` | +| **Latest version** | v0.17 (post-pbc-finance lifecycle: AR/AP entries now react to ship/receive/cancel) | +| **Latest commit** | `` | | **Repo** | https://github.com/reporkey/vibe-erp | | **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. | +| **Unit tests** | 213, all green | +| **End-to-end smoke runs** | pbc-finance now reacts to all six order events. Smoke verified: PO confirm → AP POSTED → receive → AP SETTLED. SO confirm → AR POSTED → ship → AR SETTLED. Confirm-then-cancel of either PO or SO flips the row to REVERSED. Cancel-from-DRAFT writes no row (no `*ConfirmedEvent` was ever published). All lifecycle transitions are idempotent: a duplicate settle/reverse delivery is a clean no-op, and a settle never overwrites a reversal (or vice versa). Status filter on `GET /api/v1/finance/journal-entries?status=POSTED|SETTLED|REVERSED` returns the right partition. | | **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 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. +**Foundation complete; Tier 1 customization live; authorization enforced; full buy-and-sell loop closed; event-driven cross-PBC integration live in BOTH directions; consumer PBC now reacts to the full order lifecycle.** 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 the consumer PBC reacts to all six lifecycle events** (confirm + ship/receive + cancel for both sales and purchase). The pbc-finance `OrderEventSubscribers` bean registers six typed-class subscriptions at boot. JournalEntry rows carry a lifecycle status (POSTED → SETTLED on fulfilment, POSTED → REVERSED on cancellation) and all transitions are idempotent under at-least-once delivery. Cancel-from-DRAFT is a clean no-op because no `*ConfirmedEvent` was ever published to create a row. 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. diff --git a/README.md b/README.md index ab7dc48..31a2f14 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ vibe-erp/ ## Building ```bash -# Build everything (compiles 17 modules, runs 201 unit tests) +# Build everything (compiles 17 modules, runs 213 unit tests) ./gradlew build # Bring up Postgres + the reference plug-in JAR @@ -97,7 +97,7 @@ The bootstrap admin password is printed to the application logs on first boot. A | | | |---|---| | Modules | 17 | -| Unit tests | 201, all green | +| Unit tests | 213, all green | | Real PBCs | 7 of 10 | | Cross-cutting services live | 9 | | Plug-ins serving HTTP | 1 (reference printing-shop) | diff --git a/distribution/src/main/resources/db/changelog/master.xml b/distribution/src/main/resources/db/changelog/master.xml index a3974de..341ac07 100644 --- a/distribution/src/main/resources/db/changelog/master.xml +++ b/distribution/src/main/resources/db/changelog/master.xml @@ -21,4 +21,5 @@ + diff --git a/distribution/src/main/resources/db/changelog/pbc-finance/002-finance-status.xml b/distribution/src/main/resources/db/changelog/pbc-finance/002-finance-status.xml new file mode 100644 index 0000000..a7c8f7e --- /dev/null +++ b/distribution/src/main/resources/db/changelog/pbc-finance/002-finance-status.xml @@ -0,0 +1,45 @@ + + + + + + + Add status column with POSTED default + CHECK + index + + ALTER TABLE finance__journal_entry + ADD COLUMN status varchar(16) NOT NULL DEFAULT 'POSTED'; + + ALTER TABLE finance__journal_entry + ADD CONSTRAINT finance__journal_entry_status_check + CHECK (status IN ('POSTED', 'SETTLED', 'REVERSED')); + + CREATE INDEX finance__journal_entry_status_idx + ON finance__journal_entry (status); + + + DROP INDEX finance__journal_entry_status_idx; + ALTER TABLE finance__journal_entry + DROP CONSTRAINT finance__journal_entry_status_check; + ALTER TABLE finance__journal_entry + DROP COLUMN status; + + + + 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 index 3dbb216..23134a9 100644 --- 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 @@ -4,9 +4,14 @@ 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.PurchaseOrderCancelledEvent import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent +import org.vibeerp.api.v1.event.orders.PurchaseOrderReceivedEvent +import org.vibeerp.api.v1.event.orders.SalesOrderCancelledEvent import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent +import org.vibeerp.api.v1.event.orders.SalesOrderShippedEvent import org.vibeerp.pbc.finance.domain.JournalEntry +import org.vibeerp.pbc.finance.domain.JournalEntryStatus import org.vibeerp.pbc.finance.domain.JournalEntryType import org.vibeerp.pbc.finance.infrastructure.JournalEntryJpaRepository import java.util.UUID @@ -118,6 +123,107 @@ class JournalEntryService( ) } + /** + * React to a `SalesOrderShippedEvent` by promoting the existing + * AR row to SETTLED. + * + * In a real GL this is the moment revenue is recognised — the + * order has physically left the warehouse. The minimal v0.16/v0.17 + * finance PBC just flips the lifecycle status; the future P5.9 + * build will write a separate revenue row at this point. + * + * **Idempotent.** If the row is already SETTLED (duplicate + * delivery, replay) the call is a no-op. If no AR row exists + * (the producer published a ship event without a corresponding + * confirm event — currently impossible because of the producer's + * state machine, but defensive against future event ordering + * issues) the call is also a no-op. + */ + fun settleFromSalesShipped(event: SalesOrderShippedEvent): JournalEntry? = + settleByOrderCode(event.orderCode, "SalesOrderShippedEvent") + + /** + * Mirror of [settleFromSalesShipped] for the buying side. + * Promotes the AP row to SETTLED on `PurchaseOrderReceivedEvent`. + */ + fun settleFromPurchaseReceived(event: PurchaseOrderReceivedEvent): JournalEntry? = + settleByOrderCode(event.orderCode, "PurchaseOrderReceivedEvent") + + /** + * React to a `SalesOrderCancelledEvent` by promoting the existing + * AR row to REVERSED — the order was cancelled before fulfilment, + * so the receivable should not appear in any "outstanding" report. + * + * **Idempotent and tolerant of missing rows.** Cancelling from + * DRAFT means no `*ConfirmedEvent` was ever published and no row + * was ever written; in that case the lookup returns null and + * this call is a no-op. Cancelling from CONFIRMED means the AR + * row exists and gets flipped to REVERSED. Cancelling a row that + * is already SETTLED is also a no-op (the producer's state + * machine forbids cancel-from-shipped, but defensive). + */ + fun reverseFromSalesCancelled(event: SalesOrderCancelledEvent): JournalEntry? = + reverseByOrderCode(event.orderCode, "SalesOrderCancelledEvent") + + /** + * Mirror of [reverseFromSalesCancelled] for the buying side. + * Promotes the AP row to REVERSED on `PurchaseOrderCancelledEvent`. + */ + fun reverseFromPurchaseCancelled(event: PurchaseOrderCancelledEvent): JournalEntry? = + reverseByOrderCode(event.orderCode, "PurchaseOrderCancelledEvent") + + private fun settleByOrderCode(orderCode: String, fromEvent: String): JournalEntry? { + val entry = entries.findFirstByOrderCode(orderCode) + if (entry == null) { + log.debug("[finance] {} for unknown order code {}; nothing to settle", fromEvent, orderCode) + return null + } + if (entry.status == JournalEntryStatus.SETTLED) { + log.debug("[finance] {} for already-SETTLED journal entry {}; no-op", fromEvent, entry.code) + return entry + } + if (entry.status == JournalEntryStatus.REVERSED) { + // A reversed entry can't be re-settled; that's a logical + // contradiction. Log loudly and leave the row alone. + log.warn( + "[finance] {} arrived for REVERSED journal entry {} (orderCode={}); ignoring", + fromEvent, entry.code, orderCode, + ) + return entry + } + log.info("[finance] {} {} ← {}", entry.type, "SETTLED", fromEvent) + entry.status = JournalEntryStatus.SETTLED + return entry + } + + private fun reverseByOrderCode(orderCode: String, fromEvent: String): JournalEntry? { + val entry = entries.findFirstByOrderCode(orderCode) + if (entry == null) { + // Cancelling from DRAFT — no entry was ever written + // because no *ConfirmedEvent was ever published. Quiet + // no-op, this is the most common cancel path. + log.debug("[finance] {} for orderCode {} with no journal entry; nothing to reverse", fromEvent, orderCode) + return null + } + if (entry.status == JournalEntryStatus.REVERSED) { + log.debug("[finance] {} for already-REVERSED journal entry {}; no-op", fromEvent, entry.code) + return entry + } + if (entry.status == JournalEntryStatus.SETTLED) { + // The producer's state machine forbids cancel-from-shipped/ + // received, so reaching here would imply a contract + // violation upstream. Log loudly and leave the row alone. + log.warn( + "[finance] {} arrived for SETTLED journal entry {} (orderCode={}); ignoring", + fromEvent, entry.code, orderCode, + ) + return entry + } + log.info("[finance] {} {} ← {}", entry.type, "REVERSED", fromEvent) + entry.status = JournalEntryStatus.REVERSED + return entry + } + @Transactional(readOnly = true) fun list(): List = entries.findAll() @@ -129,4 +235,7 @@ class JournalEntryService( @Transactional(readOnly = true) fun findByType(type: JournalEntryType): List = entries.findByType(type) + + @Transactional(readOnly = true) + fun findByStatus(status: JournalEntryStatus): List = entries.findByStatus(status) } 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 index edaa36b..d4930d5 100644 --- 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 @@ -51,6 +51,7 @@ class JournalEntry( amount: BigDecimal, currencyCode: String, postedAt: Instant, + status: JournalEntryStatus = JournalEntryStatus.POSTED, ) : AuditedJpaEntity() { @Column(name = "code", nullable = false, length = 64) @@ -75,12 +76,51 @@ class JournalEntry( @Column(name = "posted_at", nullable = false) var postedAt: Instant = postedAt + /** + * Lifecycle status of this entry. + * + * - **POSTED** — created in reaction to a `*ConfirmedEvent`. + * The order is committed but not yet fulfilled, so the AR/AP + * figure is "expected" rather than "settled". + * - **SETTLED** — promoted by a `SalesOrderShippedEvent` / + * `PurchaseOrderReceivedEvent`. The order has physically been + * fulfilled; in a real GL this is the moment revenue/cost is + * recognised. The minimal v0.16/v0.17 finance PBC just flips + * the status — the future P5.9 build will write a separate + * revenue/cost row at this point. + * - **REVERSED** — promoted by a `*CancelledEvent`. The order + * was cancelled before fulfilment; the AR/AP figure should + * no longer be reported as outstanding. + * + * Both transitions are idempotent — re-applying the same + * destination status is a no-op rather than an error. This makes + * the consumer correct under at-least-once delivery without a + * separate dedup table per transition. + */ + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 16) + var status: JournalEntryStatus = status + @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)" + "JournalEntry(id=$id, code='$code', type=$type, status=$status, partner='$partnerCode', order='$orderCode', amount=$amount $currencyCode)" +} + +/** + * Lifecycle status of a [JournalEntry]. + * + * See the KDoc on [JournalEntry.status] for the semantics. The + * status field is intentionally a separate axis from [JournalEntryType] + * — type tells you "is this a receivable or a payable", status tells + * you "where is it in its lifecycle". + */ +enum class JournalEntryStatus { + POSTED, + SETTLED, + REVERSED, } /** 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 index a7e92e2..bc8b742 100644 --- 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 @@ -5,8 +5,12 @@ 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.PurchaseOrderCancelledEvent import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent +import org.vibeerp.api.v1.event.orders.PurchaseOrderReceivedEvent +import org.vibeerp.api.v1.event.orders.SalesOrderCancelledEvent import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent +import org.vibeerp.api.v1.event.orders.SalesOrderShippedEvent import org.vibeerp.pbc.finance.application.JournalEntryService /** @@ -55,6 +59,7 @@ class OrderEventSubscribers( @PostConstruct fun subscribe() { + // Posting (create) — confirm events. eventBus.subscribe( SalesOrderConfirmedEvent::class.java, EventListener { event -> journal.recordSalesConfirmed(event) }, @@ -63,8 +68,30 @@ class OrderEventSubscribers( PurchaseOrderConfirmedEvent::class.java, EventListener { event -> journal.recordPurchaseConfirmed(event) }, ) + + // Settlement — fulfilment events. + eventBus.subscribe( + SalesOrderShippedEvent::class.java, + EventListener { event -> journal.settleFromSalesShipped(event) }, + ) + eventBus.subscribe( + PurchaseOrderReceivedEvent::class.java, + EventListener { event -> journal.settleFromPurchaseReceived(event) }, + ) + + // Reversal — cancellation events. + eventBus.subscribe( + SalesOrderCancelledEvent::class.java, + EventListener { event -> journal.reverseFromSalesCancelled(event) }, + ) + eventBus.subscribe( + PurchaseOrderCancelledEvent::class.java, + EventListener { event -> journal.reverseFromPurchaseCancelled(event) }, + ) + log.info( - "pbc-finance subscribed to SalesOrderConfirmedEvent and PurchaseOrderConfirmedEvent " + + "pbc-finance subscribed to 6 order events: " + + "{Sales,Purchase}Order{Confirmed,Shipped/Received,Cancelled}Event " + "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 index f7540f7..7daf10f 100644 --- 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 @@ -8,6 +8,7 @@ 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.JournalEntryStatus import org.vibeerp.pbc.finance.domain.JournalEntryType import org.vibeerp.platform.security.authz.RequirePermission import java.math.BigDecimal @@ -44,10 +45,12 @@ class JournalEntryController( fun list( @RequestParam(required = false) orderCode: String?, @RequestParam(required = false) type: JournalEntryType?, + @RequestParam(required = false) status: JournalEntryStatus?, ): List { val rows = when { orderCode != null -> journalEntryService.findByOrderCode(orderCode) type != null -> journalEntryService.findByType(type) + status != null -> journalEntryService.findByStatus(status) else -> journalEntryService.list() } return rows.map { it.toResponse() } @@ -65,6 +68,7 @@ data class JournalEntryResponse( val id: UUID, val code: String, val type: JournalEntryType, + val status: JournalEntryStatus, val partnerCode: String, val orderCode: String, val amount: BigDecimal, @@ -77,6 +81,7 @@ private fun JournalEntry.toResponse(): JournalEntryResponse = id = id, code = code, type = type, + status = status, partnerCode = partnerCode, orderCode = orderCode, amount = amount, 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 index 01f7924..4182a04 100644 --- 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 @@ -3,6 +3,7 @@ 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.JournalEntryStatus import org.vibeerp.pbc.finance.domain.JournalEntryType import java.util.UUID @@ -22,4 +23,23 @@ interface JournalEntryJpaRepository : JpaRepository { fun findByCode(code: String): JournalEntry? fun findByOrderCode(orderCode: String): List fun findByType(type: JournalEntryType): List + fun findByStatus(status: JournalEntryStatus): List + + /** + * The lookup behind [JournalEntryService.settleByOrderCode] and + * [JournalEntryService.reverseByOrderCode]. Returns null if no + * journal entry exists for the given order code, which is the + * normal case for orders cancelled from DRAFT (no `*ConfirmedEvent` + * was ever published, so no row was ever written) — both transition + * methods treat null as a clean no-op. + * + * Why "first" instead of a list: the v0.16/v0.17 model writes + * exactly one journal entry per order code (one `*ConfirmedEvent` + * → one row, with idempotency on the event id). When the future + * P5.9 build adds separate revenue / cost rows, this query will + * need to grow into a typed lookup (e.g. find the AR row, find + * the revenue row), but that's the right time to make the schema + * decision rather than now. + */ + fun findFirstByOrderCode(orderCode: String): JournalEntry? } 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 index 2f33efa..9a59c19 100644 --- 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 @@ -12,9 +12,14 @@ 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.PurchaseOrderCancelledEvent import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent +import org.vibeerp.api.v1.event.orders.PurchaseOrderReceivedEvent +import org.vibeerp.api.v1.event.orders.SalesOrderCancelledEvent import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent +import org.vibeerp.api.v1.event.orders.SalesOrderShippedEvent import org.vibeerp.pbc.finance.domain.JournalEntry +import org.vibeerp.pbc.finance.domain.JournalEntryStatus import org.vibeerp.pbc.finance.domain.JournalEntryType import org.vibeerp.pbc.finance.infrastructure.JournalEntryJpaRepository import java.math.BigDecimal @@ -164,6 +169,160 @@ class JournalEntryServiceTest { assertThat(saved[1].code).isEqualTo(purchaseId.toString()) } + // ─── settleFromSalesShipped / settleFromPurchaseReceived ───────── + + private fun postedEntry( + type: JournalEntryType = JournalEntryType.AR, + orderCode: String = "SO-1", + status: JournalEntryStatus = JournalEntryStatus.POSTED, + ): JournalEntry = + JournalEntry( + code = UUID.randomUUID().toString(), + type = type, + partnerCode = "P-1", + orderCode = orderCode, + amount = BigDecimal("10.00"), + currencyCode = "USD", + postedAt = Instant.parse("2026-04-08T00:00:00Z"), + status = status, + ) + + @Test + fun `settleFromSalesShipped flips a POSTED AR row to SETTLED`() { + val entry = postedEntry(type = JournalEntryType.AR, orderCode = "SO-9") + every { entries.findFirstByOrderCode("SO-9") } returns entry + + val result = service.settleFromSalesShipped( + SalesOrderShippedEvent(orderCode = "SO-9", partnerCode = "CUST-1", shippingLocationCode = "WH-MAIN"), + ) + + assertThat(result).isNotNull() + assertThat(entry.status).isEqualTo(JournalEntryStatus.SETTLED) + } + + @Test + fun `settleFromSalesShipped is a no-op for an already-SETTLED entry`() { + val entry = postedEntry(orderCode = "SO-1", status = JournalEntryStatus.SETTLED) + every { entries.findFirstByOrderCode("SO-1") } returns entry + + service.settleFromSalesShipped( + SalesOrderShippedEvent(orderCode = "SO-1", partnerCode = "CUST-1", shippingLocationCode = "WH-MAIN"), + ) + + assertThat(entry.status).isEqualTo(JournalEntryStatus.SETTLED) + verify(exactly = 0) { entries.save(any()) } + } + + @Test + fun `settleFromSalesShipped is a no-op when no entry exists for the order code`() { + every { entries.findFirstByOrderCode("SO-MISSING") } returns null + + val result = service.settleFromSalesShipped( + SalesOrderShippedEvent(orderCode = "SO-MISSING", partnerCode = "CUST-1", shippingLocationCode = "WH-MAIN"), + ) + + assertThat(result).isNull() + } + + @Test + fun `settleFromSalesShipped refuses to overwrite a REVERSED entry`() { + val entry = postedEntry(orderCode = "SO-X", status = JournalEntryStatus.REVERSED) + every { entries.findFirstByOrderCode("SO-X") } returns entry + + service.settleFromSalesShipped( + SalesOrderShippedEvent(orderCode = "SO-X", partnerCode = "CUST-1", shippingLocationCode = "WH-MAIN"), + ) + + // The entry stays REVERSED — settle does NOT overwrite a reversal. + assertThat(entry.status).isEqualTo(JournalEntryStatus.REVERSED) + } + + @Test + fun `settleFromPurchaseReceived flips a POSTED AP row to SETTLED`() { + val entry = postedEntry(type = JournalEntryType.AP, orderCode = "PO-9") + every { entries.findFirstByOrderCode("PO-9") } returns entry + + service.settleFromPurchaseReceived( + PurchaseOrderReceivedEvent(orderCode = "PO-9", partnerCode = "SUP-1", receivingLocationCode = "WH-MAIN"), + ) + + assertThat(entry.status).isEqualTo(JournalEntryStatus.SETTLED) + } + + // ─── reverseFromSalesCancelled / reverseFromPurchaseCancelled ──── + + @Test + fun `reverseFromSalesCancelled flips a POSTED AR row to REVERSED`() { + val entry = postedEntry(type = JournalEntryType.AR, orderCode = "SO-CANCEL") + every { entries.findFirstByOrderCode("SO-CANCEL") } returns entry + + service.reverseFromSalesCancelled( + SalesOrderCancelledEvent(orderCode = "SO-CANCEL", partnerCode = "CUST-1"), + ) + + assertThat(entry.status).isEqualTo(JournalEntryStatus.REVERSED) + } + + @Test + fun `reverseFromSalesCancelled is a no-op when the order was cancelled from DRAFT`() { + // No *ConfirmedEvent was ever published for a DRAFT cancel, + // so no journal entry exists. The lookup returns null and + // the call must be a clean no-op (the most common cancel path). + every { entries.findFirstByOrderCode("SO-DRAFT-CANCEL") } returns null + + val result = service.reverseFromSalesCancelled( + SalesOrderCancelledEvent(orderCode = "SO-DRAFT-CANCEL", partnerCode = "CUST-1"), + ) + + assertThat(result).isNull() + } + + @Test + fun `reverseFromSalesCancelled is idempotent for an already-REVERSED entry`() { + val entry = postedEntry(orderCode = "SO-1", status = JournalEntryStatus.REVERSED) + every { entries.findFirstByOrderCode("SO-1") } returns entry + + service.reverseFromSalesCancelled( + SalesOrderCancelledEvent(orderCode = "SO-1", partnerCode = "CUST-1"), + ) + + assertThat(entry.status).isEqualTo(JournalEntryStatus.REVERSED) + } + + @Test + fun `reverseFromSalesCancelled refuses to overwrite a SETTLED entry`() { + val entry = postedEntry(orderCode = "SO-1", status = JournalEntryStatus.SETTLED) + every { entries.findFirstByOrderCode("SO-1") } returns entry + + service.reverseFromSalesCancelled( + SalesOrderCancelledEvent(orderCode = "SO-1", partnerCode = "CUST-1"), + ) + + assertThat(entry.status).isEqualTo(JournalEntryStatus.SETTLED) + } + + @Test + fun `reverseFromPurchaseCancelled flips a POSTED AP row to REVERSED`() { + val entry = postedEntry(type = JournalEntryType.AP, orderCode = "PO-CANCEL") + every { entries.findFirstByOrderCode("PO-CANCEL") } returns entry + + service.reverseFromPurchaseCancelled( + PurchaseOrderCancelledEvent(orderCode = "PO-CANCEL", partnerCode = "SUP-1"), + ) + + assertThat(entry.status).isEqualTo(JournalEntryStatus.REVERSED) + } + + @Test + fun `recordSalesConfirmed writes new entries with default POSTED status`() { + val saved = slot() + every { entries.save(capture(saved)) } answers { saved.captured } + + service.recordSalesConfirmed(salesEvent()) + + assertThat(saved.captured.status).isEqualTo(JournalEntryStatus.POSTED) + } + // ─── compile-time guard: events still implement DomainEvent ────── @Test 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 index ba7086d..2f2e32c 100644 --- 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 @@ -7,14 +7,18 @@ 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.PurchaseOrderCancelledEvent import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent +import org.vibeerp.api.v1.event.orders.PurchaseOrderReceivedEvent +import org.vibeerp.api.v1.event.orders.SalesOrderCancelledEvent import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent +import org.vibeerp.api.v1.event.orders.SalesOrderShippedEvent import org.vibeerp.pbc.finance.application.JournalEntryService class OrderEventSubscribersTest { @Test - fun `subscribe registers exactly one typed listener for each of the two events`() { + fun `subscribe registers exactly one typed listener for each of the six lifecycle events`() { val eventBus = mockk(relaxed = true) val journal = mockk(relaxed = true) @@ -23,6 +27,10 @@ class OrderEventSubscribersTest { verify(exactly = 1) { eventBus.subscribe(SalesOrderConfirmedEvent::class.java, any>()) } verify(exactly = 1) { eventBus.subscribe(PurchaseOrderConfirmedEvent::class.java, any>()) } + verify(exactly = 1) { eventBus.subscribe(SalesOrderShippedEvent::class.java, any>()) } + verify(exactly = 1) { eventBus.subscribe(PurchaseOrderReceivedEvent::class.java, any>()) } + verify(exactly = 1) { eventBus.subscribe(SalesOrderCancelledEvent::class.java, any>()) } + verify(exactly = 1) { eventBus.subscribe(PurchaseOrderCancelledEvent::class.java, any>()) } } @Test @@ -68,4 +76,34 @@ class OrderEventSubscribersTest { verify(exactly = 1) { journal.recordPurchaseConfirmed(event) } } + + @Test + fun `shipped, received, and cancelled listeners forward to the correct service methods`() { + val eventBus = mockk(relaxed = true) + val journal = mockk(relaxed = true) + val shipped = slot>() + val received = slot>() + val salesCancelled = slot>() + val purchCancelled = slot>() + every { eventBus.subscribe(SalesOrderShippedEvent::class.java, capture(shipped)) } answers { mockk(relaxed = true) } + every { eventBus.subscribe(PurchaseOrderReceivedEvent::class.java, capture(received)) } answers { mockk(relaxed = true) } + every { eventBus.subscribe(SalesOrderCancelledEvent::class.java, capture(salesCancelled)) } answers { mockk(relaxed = true) } + every { eventBus.subscribe(PurchaseOrderCancelledEvent::class.java, capture(purchCancelled)) } answers { mockk(relaxed = true) } + + OrderEventSubscribers(eventBus, journal).subscribe() + + val shipEvent = SalesOrderShippedEvent(orderCode = "SO-1", partnerCode = "CUST-1", shippingLocationCode = "WH-MAIN") + val recvEvent = PurchaseOrderReceivedEvent(orderCode = "PO-1", partnerCode = "SUP-1", receivingLocationCode = "WH-MAIN") + val soCancelEvent = SalesOrderCancelledEvent(orderCode = "SO-2", partnerCode = "CUST-1") + val poCancelEvent = PurchaseOrderCancelledEvent(orderCode = "PO-2", partnerCode = "SUP-1") + shipped.captured.handle(shipEvent) + received.captured.handle(recvEvent) + salesCancelled.captured.handle(soCancelEvent) + purchCancelled.captured.handle(poCancelEvent) + + verify(exactly = 1) { journal.settleFromSalesShipped(shipEvent) } + verify(exactly = 1) { journal.settleFromPurchaseReceived(recvEvent) } + verify(exactly = 1) { journal.reverseFromSalesCancelled(soCancelEvent) } + verify(exactly = 1) { journal.reverseFromPurchaseCancelled(poCancelEvent) } + } } -- libgit2 0.22.2