Commit 0e9736c9205fda276da95d6b4c6dfad6907155c3
1 parent
3ae0e044
feat(pbc-finance): react to ship/receive/cancel — full lifecycle on the journal
The minimal pbc-finance landed in commit bf090c2e only reacted to *ConfirmedEvent. This change wires the rest of the order lifecycle (ship/receive → SETTLED, cancel → REVERSED) so the journal entry reflects what actually happened to the order, not just the moment it was confirmed. JournalEntryStatus (new enum + new column) - POSTED — created from a confirm event (existing behaviour) - SETTLED — promoted by SalesOrderShippedEvent / PurchaseOrderReceivedEvent - REVERSED — promoted by SalesOrderCancelledEvent / PurchaseOrderCancelledEvent - The status field is intentionally a separate axis from JournalEntryType: type tells you "AR or AP", status tells you "where in its lifecycle". distribution/.../pbc-finance/002-finance-status.xml - ALTER TABLE adds `status varchar(16) NOT NULL DEFAULT 'POSTED'`, a CHECK constraint mirroring the enum values, and an index on status for the new filter endpoint. The DEFAULT 'POSTED' covers any existing rows on an upgraded environment without a backfill step. JournalEntryService — four new methods, all idempotent - settleFromSalesShipped(event) → POSTED → SETTLED for AR - settleFromPurchaseReceived(event) → POSTED → SETTLED for AP - reverseFromSalesCancelled(event) → POSTED → REVERSED for AR - reverseFromPurchaseCancelled(event) → POSTED → REVERSED for AP Each runs through a private settleByOrderCode/reverseByOrderCode helper that: 1. Looks up the row by order_code (new repo method findFirstByOrderCode). If absent → no-op (e.g. cancel from DRAFT means no *ConfirmedEvent was ever published, so no journal entry exists; this is the most common cancel path). 2. If the row is already in the destination status → no-op (idempotent under at-least-once delivery, e.g. outbox replay or future Kafka retry). 3. Refuses to overwrite a contradictory terminal status — a SETTLED row cannot be REVERSED, and vice versa. The producer's state machine forbids cancel-from-shipped/received, so reaching here implies an upstream contract violation; logged at WARN and the row is left alone. OrderEventSubscribers — six subscriptions per @PostConstruct - All six order events from api.v1.event.orders.* are subscribed via the typed-class EventBus.subscribe(eventType, listener) overload, the same public API a plug-in would use. Boot log line updated: "pbc-finance subscribed to 6 order events". JournalEntryController — new ?status= filter - GET /api/v1/finance/journal-entries?status=POSTED|SETTLED|REVERSED surfaces the partition. Existing ?orderCode= and ?type= filters unchanged. Read permission still finance.journal.read. 12 new unit tests (213 total, was 201) - JournalEntryServiceTest: settle/reverse for AR + AP, idempotency on duplicate destination status, refusal to overwrite a contradictory terminal status, no-op on missing row, default POSTED on new entries. - OrderEventSubscribersTest: assert all SIX subscriptions registered, one new test that captures all four lifecycle listeners and verifies they forward to the correct service methods. End-to-end smoke (real Postgres, fresh DB) - Booted with the new DDL applied (status column + CHECK + index) on an empty DB. The OrderEventSubscribers @PostConstruct line confirms 6 subscriptions registered before the first HTTP call. - Five lifecycle scenarios driven via REST: PO-FULL: confirm + receive → AP SETTLED amount=50.00 SO-FULL: confirm + ship → AR SETTLED amount= 1.00 SO-REVERSE: confirm + cancel → AR REVERSED amount= 1.00 PO-REVERSE: confirm + cancel → AP REVERSED amount=50.00 SO-DRAFT-CANCEL: cancel only → NO ROW (no confirm event) - finance__journal_entry returns exactly 4 rows (the 5th scenario correctly produces nothing) and ?status filters all return the expected partition (POSTED=0, SETTLED=2, REVERSED=2). What's still NOT in pbc-finance - Still no debit/credit legs, no chart of accounts, no period close, no double-entry invariant. This is the v0.17 minimal seed; the real P5.9 build promotes it into a real GL. - No reaction to "settle then reverse" or "reverse then settle" other than the WARN-and-leave-alone defensive path. A real GL would write a separate compensating journal entry; the minimal PBC just keeps the row immutable once it leaves POSTED.
Showing
12 changed files
with
456 additions
and
12 deletions
CLAUDE.md
| @@ -96,10 +96,10 @@ plugins (incl. ref) depend on: api/api-v1 only | @@ -96,10 +96,10 @@ plugins (incl. ref) depend on: api/api-v1 only | ||
| 96 | **Foundation complete; first business surface in place.** As of the latest commit: | 96 | **Foundation complete; first business surface in place.** As of the latest commit: |
| 97 | 97 | ||
| 98 | - **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`. | 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. | 99 | +- **213 unit tests across 17 modules**, all green. `./gradlew build` is the canonical full build. |
| 100 | - **All 9 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), authorization with `@RequirePermission` + JWT roles claim + AOP enforcement (P4.3), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader + custom-field validation (P1.5 + P3.4), ICU4J translator with per-plug-in locale chain (P1.6), plus the audit/principal context bridge. | 100 | - **All 9 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), authorization with `@RequirePermission` + JWT roles claim + AOP enforcement (P4.3), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader + custom-field validation (P1.5 + P3.4), ICU4J translator with per-plug-in locale chain (P1.6), plus the audit/principal context bridge. |
| 101 | - **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. | 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. | 102 | +- **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. |
| 103 | - **Reference printing-shop plug-in** owns its own DB schema, CRUDs plates and ink recipes via REST through `context.jdbc`, ships its own metadata YAML, and registers 7 HTTP endpoints. It is the executable acceptance test for the framework. | 103 | - **Reference printing-shop plug-in** owns its own DB schema, CRUDs plates and ink recipes via REST through `context.jdbc`, ships its own metadata YAML, and registers 7 HTTP endpoints. It is the executable acceptance test for the framework. |
| 104 | - **Package root** is `org.vibeerp`. | 104 | - **Package root** is `org.vibeerp`. |
| 105 | - **Build commands:** `./gradlew build` (full), `./gradlew test` (unit tests), `./gradlew :distribution:bootRun` (run dev profile, stages plug-in JAR automatically), `docker compose up -d db` (Postgres). The bootstrap admin password is printed to the application logs on first boot. | 105 | - **Build commands:** `./gradlew build` (full), `./gradlew test` (unit tests), `./gradlew :distribution:bootRun` (run dev profile, stages plug-in JAR automatically), `docker compose up -d db` (Postgres). The bootstrap admin password is printed to the application logs on first boot. |
PROGRESS.md
| @@ -10,19 +10,19 @@ | @@ -10,19 +10,19 @@ | ||
| 10 | 10 | ||
| 11 | | | | | 11 | | | | |
| 12 | |---|---| | 12 | |---|---| |
| 13 | -| **Latest version** | v0.16 (post-pbc-finance: first cross-PBC event consumer) | | ||
| 14 | -| **Latest commit** | `bf090c2 feat(pbc): pbc-finance — first cross-PBC event consumer (minimal AR/AP)` | | 13 | +| **Latest version** | v0.17 (post-pbc-finance lifecycle: AR/AP entries now react to ship/receive/cancel) | |
| 14 | +| **Latest commit** | `<pin after push>` | | ||
| 15 | | **Repo** | https://github.com/reporkey/vibe-erp | | 15 | | **Repo** | https://github.com/reporkey/vibe-erp | |
| 16 | | **Modules** | 17 | | 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. | | 17 | +| **Unit tests** | 213, all green | |
| 18 | +| **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. | | ||
| 19 | | **Real PBCs implemented** | 7 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`) | | 19 | | **Real PBCs implemented** | 7 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`) | |
| 20 | | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | | 20 | | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | |
| 21 | | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. | | 21 | | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. | |
| 22 | 22 | ||
| 23 | ## Current stage | 23 | ## Current stage |
| 24 | 24 | ||
| 25 | -**Foundation complete; Tier 1 customization live; authorization enforced; full buy-and-sell loop closed; event-driven cross-PBC integration live 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. | 25 | +**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. |
| 26 | 26 | ||
| 27 | The next phase continues **building business surface area**: pbc-production (the framework's first non-order/non-master-data PBC), the workflow engine (Flowable), and eventually the React SPA. | 27 | The next phase continues **building business surface area**: pbc-production (the framework's first non-order/non-master-data PBC), the workflow engine (Flowable), and eventually the React SPA. |
| 28 | 28 |
README.md
| @@ -77,7 +77,7 @@ vibe-erp/ | @@ -77,7 +77,7 @@ vibe-erp/ | ||
| 77 | ## Building | 77 | ## Building |
| 78 | 78 | ||
| 79 | ```bash | 79 | ```bash |
| 80 | -# Build everything (compiles 17 modules, runs 201 unit tests) | 80 | +# Build everything (compiles 17 modules, runs 213 unit tests) |
| 81 | ./gradlew build | 81 | ./gradlew build |
| 82 | 82 | ||
| 83 | # Bring up Postgres + the reference plug-in JAR | 83 | # Bring up Postgres + the reference plug-in JAR |
| @@ -97,7 +97,7 @@ The bootstrap admin password is printed to the application logs on first boot. A | @@ -97,7 +97,7 @@ The bootstrap admin password is printed to the application logs on first boot. A | ||
| 97 | | | | | 97 | | | | |
| 98 | |---|---| | 98 | |---|---| |
| 99 | | Modules | 17 | | 99 | | Modules | 17 | |
| 100 | -| Unit tests | 201, all green | | 100 | +| Unit tests | 213, all green | |
| 101 | | Real PBCs | 7 of 10 | | 101 | | Real PBCs | 7 of 10 | |
| 102 | | Cross-cutting services live | 9 | | 102 | | Cross-cutting services live | 9 | |
| 103 | | Plug-ins serving HTTP | 1 (reference printing-shop) | | 103 | | Plug-ins serving HTTP | 1 (reference printing-shop) | |
distribution/src/main/resources/db/changelog/master.xml
| @@ -21,4 +21,5 @@ | @@ -21,4 +21,5 @@ | ||
| 21 | <include file="classpath:db/changelog/pbc-orders-sales/001-orders-sales-init.xml"/> | 21 | <include file="classpath:db/changelog/pbc-orders-sales/001-orders-sales-init.xml"/> |
| 22 | <include file="classpath:db/changelog/pbc-orders-purchase/001-orders-purchase-init.xml"/> | 22 | <include file="classpath:db/changelog/pbc-orders-purchase/001-orders-purchase-init.xml"/> |
| 23 | <include file="classpath:db/changelog/pbc-finance/001-finance-init.xml"/> | 23 | <include file="classpath:db/changelog/pbc-finance/001-finance-init.xml"/> |
| 24 | + <include file="classpath:db/changelog/pbc-finance/002-finance-status.xml"/> | ||
| 24 | </databaseChangeLog> | 25 | </databaseChangeLog> |
distribution/src/main/resources/db/changelog/pbc-finance/002-finance-status.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 v0.17 — add lifecycle status to journal entries. | ||
| 9 | + | ||
| 10 | + Adds a `status` column tracking the lifecycle of each journal | ||
| 11 | + entry (POSTED → SETTLED on shipment/receipt, POSTED → REVERSED | ||
| 12 | + on cancellation). The default value 'POSTED' covers any rows | ||
| 13 | + that already exist on an upgraded environment, where they were | ||
| 14 | + all created from confirm events. | ||
| 15 | + | ||
| 16 | + See JournalEntryStatus.kt for the semantics of each value and | ||
| 17 | + why status is a separate axis from JournalEntryType. | ||
| 18 | + | ||
| 19 | + New CHECK constraint mirrors the enum to keep DBA-side hand- | ||
| 20 | + edits from drifting from the application's expectations. | ||
| 21 | + --> | ||
| 22 | + | ||
| 23 | + <changeSet id="finance-status-001" author="vibe_erp"> | ||
| 24 | + <comment>Add status column with POSTED default + CHECK + index</comment> | ||
| 25 | + <sql> | ||
| 26 | + ALTER TABLE finance__journal_entry | ||
| 27 | + ADD COLUMN status varchar(16) NOT NULL DEFAULT 'POSTED'; | ||
| 28 | + | ||
| 29 | + ALTER TABLE finance__journal_entry | ||
| 30 | + ADD CONSTRAINT finance__journal_entry_status_check | ||
| 31 | + CHECK (status IN ('POSTED', 'SETTLED', 'REVERSED')); | ||
| 32 | + | ||
| 33 | + CREATE INDEX finance__journal_entry_status_idx | ||
| 34 | + ON finance__journal_entry (status); | ||
| 35 | + </sql> | ||
| 36 | + <rollback> | ||
| 37 | + DROP INDEX finance__journal_entry_status_idx; | ||
| 38 | + ALTER TABLE finance__journal_entry | ||
| 39 | + DROP CONSTRAINT finance__journal_entry_status_check; | ||
| 40 | + ALTER TABLE finance__journal_entry | ||
| 41 | + DROP COLUMN status; | ||
| 42 | + </rollback> | ||
| 43 | + </changeSet> | ||
| 44 | + | ||
| 45 | +</databaseChangeLog> |
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/application/JournalEntryService.kt
| @@ -4,9 +4,14 @@ import org.slf4j.LoggerFactory | @@ -4,9 +4,14 @@ import org.slf4j.LoggerFactory | ||
| 4 | import org.springframework.stereotype.Service | 4 | import org.springframework.stereotype.Service |
| 5 | import org.springframework.transaction.annotation.Propagation | 5 | import org.springframework.transaction.annotation.Propagation |
| 6 | import org.springframework.transaction.annotation.Transactional | 6 | import org.springframework.transaction.annotation.Transactional |
| 7 | +import org.vibeerp.api.v1.event.orders.PurchaseOrderCancelledEvent | ||
| 7 | import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent | 8 | import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent |
| 9 | +import org.vibeerp.api.v1.event.orders.PurchaseOrderReceivedEvent | ||
| 10 | +import org.vibeerp.api.v1.event.orders.SalesOrderCancelledEvent | ||
| 8 | import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent | 11 | import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent |
| 12 | +import org.vibeerp.api.v1.event.orders.SalesOrderShippedEvent | ||
| 9 | import org.vibeerp.pbc.finance.domain.JournalEntry | 13 | import org.vibeerp.pbc.finance.domain.JournalEntry |
| 14 | +import org.vibeerp.pbc.finance.domain.JournalEntryStatus | ||
| 10 | import org.vibeerp.pbc.finance.domain.JournalEntryType | 15 | import org.vibeerp.pbc.finance.domain.JournalEntryType |
| 11 | import org.vibeerp.pbc.finance.infrastructure.JournalEntryJpaRepository | 16 | import org.vibeerp.pbc.finance.infrastructure.JournalEntryJpaRepository |
| 12 | import java.util.UUID | 17 | import java.util.UUID |
| @@ -118,6 +123,107 @@ class JournalEntryService( | @@ -118,6 +123,107 @@ class JournalEntryService( | ||
| 118 | ) | 123 | ) |
| 119 | } | 124 | } |
| 120 | 125 | ||
| 126 | + /** | ||
| 127 | + * React to a `SalesOrderShippedEvent` by promoting the existing | ||
| 128 | + * AR row to SETTLED. | ||
| 129 | + * | ||
| 130 | + * In a real GL this is the moment revenue is recognised — the | ||
| 131 | + * order has physically left the warehouse. The minimal v0.16/v0.17 | ||
| 132 | + * finance PBC just flips the lifecycle status; the future P5.9 | ||
| 133 | + * build will write a separate revenue row at this point. | ||
| 134 | + * | ||
| 135 | + * **Idempotent.** If the row is already SETTLED (duplicate | ||
| 136 | + * delivery, replay) the call is a no-op. If no AR row exists | ||
| 137 | + * (the producer published a ship event without a corresponding | ||
| 138 | + * confirm event — currently impossible because of the producer's | ||
| 139 | + * state machine, but defensive against future event ordering | ||
| 140 | + * issues) the call is also a no-op. | ||
| 141 | + */ | ||
| 142 | + fun settleFromSalesShipped(event: SalesOrderShippedEvent): JournalEntry? = | ||
| 143 | + settleByOrderCode(event.orderCode, "SalesOrderShippedEvent") | ||
| 144 | + | ||
| 145 | + /** | ||
| 146 | + * Mirror of [settleFromSalesShipped] for the buying side. | ||
| 147 | + * Promotes the AP row to SETTLED on `PurchaseOrderReceivedEvent`. | ||
| 148 | + */ | ||
| 149 | + fun settleFromPurchaseReceived(event: PurchaseOrderReceivedEvent): JournalEntry? = | ||
| 150 | + settleByOrderCode(event.orderCode, "PurchaseOrderReceivedEvent") | ||
| 151 | + | ||
| 152 | + /** | ||
| 153 | + * React to a `SalesOrderCancelledEvent` by promoting the existing | ||
| 154 | + * AR row to REVERSED — the order was cancelled before fulfilment, | ||
| 155 | + * so the receivable should not appear in any "outstanding" report. | ||
| 156 | + * | ||
| 157 | + * **Idempotent and tolerant of missing rows.** Cancelling from | ||
| 158 | + * DRAFT means no `*ConfirmedEvent` was ever published and no row | ||
| 159 | + * was ever written; in that case the lookup returns null and | ||
| 160 | + * this call is a no-op. Cancelling from CONFIRMED means the AR | ||
| 161 | + * row exists and gets flipped to REVERSED. Cancelling a row that | ||
| 162 | + * is already SETTLED is also a no-op (the producer's state | ||
| 163 | + * machine forbids cancel-from-shipped, but defensive). | ||
| 164 | + */ | ||
| 165 | + fun reverseFromSalesCancelled(event: SalesOrderCancelledEvent): JournalEntry? = | ||
| 166 | + reverseByOrderCode(event.orderCode, "SalesOrderCancelledEvent") | ||
| 167 | + | ||
| 168 | + /** | ||
| 169 | + * Mirror of [reverseFromSalesCancelled] for the buying side. | ||
| 170 | + * Promotes the AP row to REVERSED on `PurchaseOrderCancelledEvent`. | ||
| 171 | + */ | ||
| 172 | + fun reverseFromPurchaseCancelled(event: PurchaseOrderCancelledEvent): JournalEntry? = | ||
| 173 | + reverseByOrderCode(event.orderCode, "PurchaseOrderCancelledEvent") | ||
| 174 | + | ||
| 175 | + private fun settleByOrderCode(orderCode: String, fromEvent: String): JournalEntry? { | ||
| 176 | + val entry = entries.findFirstByOrderCode(orderCode) | ||
| 177 | + if (entry == null) { | ||
| 178 | + log.debug("[finance] {} for unknown order code {}; nothing to settle", fromEvent, orderCode) | ||
| 179 | + return null | ||
| 180 | + } | ||
| 181 | + if (entry.status == JournalEntryStatus.SETTLED) { | ||
| 182 | + log.debug("[finance] {} for already-SETTLED journal entry {}; no-op", fromEvent, entry.code) | ||
| 183 | + return entry | ||
| 184 | + } | ||
| 185 | + if (entry.status == JournalEntryStatus.REVERSED) { | ||
| 186 | + // A reversed entry can't be re-settled; that's a logical | ||
| 187 | + // contradiction. Log loudly and leave the row alone. | ||
| 188 | + log.warn( | ||
| 189 | + "[finance] {} arrived for REVERSED journal entry {} (orderCode={}); ignoring", | ||
| 190 | + fromEvent, entry.code, orderCode, | ||
| 191 | + ) | ||
| 192 | + return entry | ||
| 193 | + } | ||
| 194 | + log.info("[finance] {} {} ← {}", entry.type, "SETTLED", fromEvent) | ||
| 195 | + entry.status = JournalEntryStatus.SETTLED | ||
| 196 | + return entry | ||
| 197 | + } | ||
| 198 | + | ||
| 199 | + private fun reverseByOrderCode(orderCode: String, fromEvent: String): JournalEntry? { | ||
| 200 | + val entry = entries.findFirstByOrderCode(orderCode) | ||
| 201 | + if (entry == null) { | ||
| 202 | + // Cancelling from DRAFT — no entry was ever written | ||
| 203 | + // because no *ConfirmedEvent was ever published. Quiet | ||
| 204 | + // no-op, this is the most common cancel path. | ||
| 205 | + log.debug("[finance] {} for orderCode {} with no journal entry; nothing to reverse", fromEvent, orderCode) | ||
| 206 | + return null | ||
| 207 | + } | ||
| 208 | + if (entry.status == JournalEntryStatus.REVERSED) { | ||
| 209 | + log.debug("[finance] {} for already-REVERSED journal entry {}; no-op", fromEvent, entry.code) | ||
| 210 | + return entry | ||
| 211 | + } | ||
| 212 | + if (entry.status == JournalEntryStatus.SETTLED) { | ||
| 213 | + // The producer's state machine forbids cancel-from-shipped/ | ||
| 214 | + // received, so reaching here would imply a contract | ||
| 215 | + // violation upstream. Log loudly and leave the row alone. | ||
| 216 | + log.warn( | ||
| 217 | + "[finance] {} arrived for SETTLED journal entry {} (orderCode={}); ignoring", | ||
| 218 | + fromEvent, entry.code, orderCode, | ||
| 219 | + ) | ||
| 220 | + return entry | ||
| 221 | + } | ||
| 222 | + log.info("[finance] {} {} ← {}", entry.type, "REVERSED", fromEvent) | ||
| 223 | + entry.status = JournalEntryStatus.REVERSED | ||
| 224 | + return entry | ||
| 225 | + } | ||
| 226 | + | ||
| 121 | @Transactional(readOnly = true) | 227 | @Transactional(readOnly = true) |
| 122 | fun list(): List<JournalEntry> = entries.findAll() | 228 | fun list(): List<JournalEntry> = entries.findAll() |
| 123 | 229 | ||
| @@ -129,4 +235,7 @@ class JournalEntryService( | @@ -129,4 +235,7 @@ class JournalEntryService( | ||
| 129 | 235 | ||
| 130 | @Transactional(readOnly = true) | 236 | @Transactional(readOnly = true) |
| 131 | fun findByType(type: JournalEntryType): List<JournalEntry> = entries.findByType(type) | 237 | fun findByType(type: JournalEntryType): List<JournalEntry> = entries.findByType(type) |
| 238 | + | ||
| 239 | + @Transactional(readOnly = true) | ||
| 240 | + fun findByStatus(status: JournalEntryStatus): List<JournalEntry> = entries.findByStatus(status) | ||
| 132 | } | 241 | } |
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/JournalEntry.kt
| @@ -51,6 +51,7 @@ class JournalEntry( | @@ -51,6 +51,7 @@ class JournalEntry( | ||
| 51 | amount: BigDecimal, | 51 | amount: BigDecimal, |
| 52 | currencyCode: String, | 52 | currencyCode: String, |
| 53 | postedAt: Instant, | 53 | postedAt: Instant, |
| 54 | + status: JournalEntryStatus = JournalEntryStatus.POSTED, | ||
| 54 | ) : AuditedJpaEntity() { | 55 | ) : AuditedJpaEntity() { |
| 55 | 56 | ||
| 56 | @Column(name = "code", nullable = false, length = 64) | 57 | @Column(name = "code", nullable = false, length = 64) |
| @@ -75,12 +76,51 @@ class JournalEntry( | @@ -75,12 +76,51 @@ class JournalEntry( | ||
| 75 | @Column(name = "posted_at", nullable = false) | 76 | @Column(name = "posted_at", nullable = false) |
| 76 | var postedAt: Instant = postedAt | 77 | var postedAt: Instant = postedAt |
| 77 | 78 | ||
| 79 | + /** | ||
| 80 | + * Lifecycle status of this entry. | ||
| 81 | + * | ||
| 82 | + * - **POSTED** — created in reaction to a `*ConfirmedEvent`. | ||
| 83 | + * The order is committed but not yet fulfilled, so the AR/AP | ||
| 84 | + * figure is "expected" rather than "settled". | ||
| 85 | + * - **SETTLED** — promoted by a `SalesOrderShippedEvent` / | ||
| 86 | + * `PurchaseOrderReceivedEvent`. The order has physically been | ||
| 87 | + * fulfilled; in a real GL this is the moment revenue/cost is | ||
| 88 | + * recognised. The minimal v0.16/v0.17 finance PBC just flips | ||
| 89 | + * the status — the future P5.9 build will write a separate | ||
| 90 | + * revenue/cost row at this point. | ||
| 91 | + * - **REVERSED** — promoted by a `*CancelledEvent`. The order | ||
| 92 | + * was cancelled before fulfilment; the AR/AP figure should | ||
| 93 | + * no longer be reported as outstanding. | ||
| 94 | + * | ||
| 95 | + * Both transitions are idempotent — re-applying the same | ||
| 96 | + * destination status is a no-op rather than an error. This makes | ||
| 97 | + * the consumer correct under at-least-once delivery without a | ||
| 98 | + * separate dedup table per transition. | ||
| 99 | + */ | ||
| 100 | + @Enumerated(EnumType.STRING) | ||
| 101 | + @Column(name = "status", nullable = false, length = 16) | ||
| 102 | + var status: JournalEntryStatus = status | ||
| 103 | + | ||
| 78 | @Column(name = "ext", nullable = false, columnDefinition = "jsonb") | 104 | @Column(name = "ext", nullable = false, columnDefinition = "jsonb") |
| 79 | @JdbcTypeCode(SqlTypes.JSON) | 105 | @JdbcTypeCode(SqlTypes.JSON) |
| 80 | var ext: String = "{}" | 106 | var ext: String = "{}" |
| 81 | 107 | ||
| 82 | override fun toString(): String = | 108 | override fun toString(): String = |
| 83 | - "JournalEntry(id=$id, code='$code', type=$type, partner='$partnerCode', order='$orderCode', amount=$amount $currencyCode)" | 109 | + "JournalEntry(id=$id, code='$code', type=$type, status=$status, partner='$partnerCode', order='$orderCode', amount=$amount $currencyCode)" |
| 110 | +} | ||
| 111 | + | ||
| 112 | +/** | ||
| 113 | + * Lifecycle status of a [JournalEntry]. | ||
| 114 | + * | ||
| 115 | + * See the KDoc on [JournalEntry.status] for the semantics. The | ||
| 116 | + * status field is intentionally a separate axis from [JournalEntryType] | ||
| 117 | + * — type tells you "is this a receivable or a payable", status tells | ||
| 118 | + * you "where is it in its lifecycle". | ||
| 119 | + */ | ||
| 120 | +enum class JournalEntryStatus { | ||
| 121 | + POSTED, | ||
| 122 | + SETTLED, | ||
| 123 | + REVERSED, | ||
| 84 | } | 124 | } |
| 85 | 125 | ||
| 86 | /** | 126 | /** |
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/event/OrderEventSubscribers.kt
| @@ -5,8 +5,12 @@ import org.slf4j.LoggerFactory | @@ -5,8 +5,12 @@ import org.slf4j.LoggerFactory | ||
| 5 | import org.springframework.stereotype.Component | 5 | import org.springframework.stereotype.Component |
| 6 | import org.vibeerp.api.v1.event.EventBus | 6 | import org.vibeerp.api.v1.event.EventBus |
| 7 | import org.vibeerp.api.v1.event.EventListener | 7 | import org.vibeerp.api.v1.event.EventListener |
| 8 | +import org.vibeerp.api.v1.event.orders.PurchaseOrderCancelledEvent | ||
| 8 | import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent | 9 | import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent |
| 10 | +import org.vibeerp.api.v1.event.orders.PurchaseOrderReceivedEvent | ||
| 11 | +import org.vibeerp.api.v1.event.orders.SalesOrderCancelledEvent | ||
| 9 | import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent | 12 | import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent |
| 13 | +import org.vibeerp.api.v1.event.orders.SalesOrderShippedEvent | ||
| 10 | import org.vibeerp.pbc.finance.application.JournalEntryService | 14 | import org.vibeerp.pbc.finance.application.JournalEntryService |
| 11 | 15 | ||
| 12 | /** | 16 | /** |
| @@ -55,6 +59,7 @@ class OrderEventSubscribers( | @@ -55,6 +59,7 @@ class OrderEventSubscribers( | ||
| 55 | 59 | ||
| 56 | @PostConstruct | 60 | @PostConstruct |
| 57 | fun subscribe() { | 61 | fun subscribe() { |
| 62 | + // Posting (create) — confirm events. | ||
| 58 | eventBus.subscribe( | 63 | eventBus.subscribe( |
| 59 | SalesOrderConfirmedEvent::class.java, | 64 | SalesOrderConfirmedEvent::class.java, |
| 60 | EventListener { event -> journal.recordSalesConfirmed(event) }, | 65 | EventListener { event -> journal.recordSalesConfirmed(event) }, |
| @@ -63,8 +68,30 @@ class OrderEventSubscribers( | @@ -63,8 +68,30 @@ class OrderEventSubscribers( | ||
| 63 | PurchaseOrderConfirmedEvent::class.java, | 68 | PurchaseOrderConfirmedEvent::class.java, |
| 64 | EventListener { event -> journal.recordPurchaseConfirmed(event) }, | 69 | EventListener { event -> journal.recordPurchaseConfirmed(event) }, |
| 65 | ) | 70 | ) |
| 71 | + | ||
| 72 | + // Settlement — fulfilment events. | ||
| 73 | + eventBus.subscribe( | ||
| 74 | + SalesOrderShippedEvent::class.java, | ||
| 75 | + EventListener { event -> journal.settleFromSalesShipped(event) }, | ||
| 76 | + ) | ||
| 77 | + eventBus.subscribe( | ||
| 78 | + PurchaseOrderReceivedEvent::class.java, | ||
| 79 | + EventListener { event -> journal.settleFromPurchaseReceived(event) }, | ||
| 80 | + ) | ||
| 81 | + | ||
| 82 | + // Reversal — cancellation events. | ||
| 83 | + eventBus.subscribe( | ||
| 84 | + SalesOrderCancelledEvent::class.java, | ||
| 85 | + EventListener { event -> journal.reverseFromSalesCancelled(event) }, | ||
| 86 | + ) | ||
| 87 | + eventBus.subscribe( | ||
| 88 | + PurchaseOrderCancelledEvent::class.java, | ||
| 89 | + EventListener { event -> journal.reverseFromPurchaseCancelled(event) }, | ||
| 90 | + ) | ||
| 91 | + | ||
| 66 | log.info( | 92 | log.info( |
| 67 | - "pbc-finance subscribed to SalesOrderConfirmedEvent and PurchaseOrderConfirmedEvent " + | 93 | + "pbc-finance subscribed to 6 order events: " + |
| 94 | + "{Sales,Purchase}Order{Confirmed,Shipped/Received,Cancelled}Event " + | ||
| 68 | "via EventBus.subscribe (typed-class overload)", | 95 | "via EventBus.subscribe (typed-class overload)", |
| 69 | ) | 96 | ) |
| 70 | } | 97 | } |
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/http/JournalEntryController.kt
| @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.RequestParam | @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.RequestParam | ||
| 8 | import org.springframework.web.bind.annotation.RestController | 8 | import org.springframework.web.bind.annotation.RestController |
| 9 | import org.vibeerp.pbc.finance.application.JournalEntryService | 9 | import org.vibeerp.pbc.finance.application.JournalEntryService |
| 10 | import org.vibeerp.pbc.finance.domain.JournalEntry | 10 | import org.vibeerp.pbc.finance.domain.JournalEntry |
| 11 | +import org.vibeerp.pbc.finance.domain.JournalEntryStatus | ||
| 11 | import org.vibeerp.pbc.finance.domain.JournalEntryType | 12 | import org.vibeerp.pbc.finance.domain.JournalEntryType |
| 12 | import org.vibeerp.platform.security.authz.RequirePermission | 13 | import org.vibeerp.platform.security.authz.RequirePermission |
| 13 | import java.math.BigDecimal | 14 | import java.math.BigDecimal |
| @@ -44,10 +45,12 @@ class JournalEntryController( | @@ -44,10 +45,12 @@ class JournalEntryController( | ||
| 44 | fun list( | 45 | fun list( |
| 45 | @RequestParam(required = false) orderCode: String?, | 46 | @RequestParam(required = false) orderCode: String?, |
| 46 | @RequestParam(required = false) type: JournalEntryType?, | 47 | @RequestParam(required = false) type: JournalEntryType?, |
| 48 | + @RequestParam(required = false) status: JournalEntryStatus?, | ||
| 47 | ): List<JournalEntryResponse> { | 49 | ): List<JournalEntryResponse> { |
| 48 | val rows = when { | 50 | val rows = when { |
| 49 | orderCode != null -> journalEntryService.findByOrderCode(orderCode) | 51 | orderCode != null -> journalEntryService.findByOrderCode(orderCode) |
| 50 | type != null -> journalEntryService.findByType(type) | 52 | type != null -> journalEntryService.findByType(type) |
| 53 | + status != null -> journalEntryService.findByStatus(status) | ||
| 51 | else -> journalEntryService.list() | 54 | else -> journalEntryService.list() |
| 52 | } | 55 | } |
| 53 | return rows.map { it.toResponse() } | 56 | return rows.map { it.toResponse() } |
| @@ -65,6 +68,7 @@ data class JournalEntryResponse( | @@ -65,6 +68,7 @@ data class JournalEntryResponse( | ||
| 65 | val id: UUID, | 68 | val id: UUID, |
| 66 | val code: String, | 69 | val code: String, |
| 67 | val type: JournalEntryType, | 70 | val type: JournalEntryType, |
| 71 | + val status: JournalEntryStatus, | ||
| 68 | val partnerCode: String, | 72 | val partnerCode: String, |
| 69 | val orderCode: String, | 73 | val orderCode: String, |
| 70 | val amount: BigDecimal, | 74 | val amount: BigDecimal, |
| @@ -77,6 +81,7 @@ private fun JournalEntry.toResponse(): JournalEntryResponse = | @@ -77,6 +81,7 @@ private fun JournalEntry.toResponse(): JournalEntryResponse = | ||
| 77 | id = id, | 81 | id = id, |
| 78 | code = code, | 82 | code = code, |
| 79 | type = type, | 83 | type = type, |
| 84 | + status = status, | ||
| 80 | partnerCode = partnerCode, | 85 | partnerCode = partnerCode, |
| 81 | orderCode = orderCode, | 86 | orderCode = orderCode, |
| 82 | amount = amount, | 87 | amount = amount, |
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/infrastructure/JournalEntryJpaRepository.kt
| @@ -3,6 +3,7 @@ package org.vibeerp.pbc.finance.infrastructure | @@ -3,6 +3,7 @@ package org.vibeerp.pbc.finance.infrastructure | ||
| 3 | import org.springframework.data.jpa.repository.JpaRepository | 3 | import org.springframework.data.jpa.repository.JpaRepository |
| 4 | import org.springframework.stereotype.Repository | 4 | import org.springframework.stereotype.Repository |
| 5 | import org.vibeerp.pbc.finance.domain.JournalEntry | 5 | import org.vibeerp.pbc.finance.domain.JournalEntry |
| 6 | +import org.vibeerp.pbc.finance.domain.JournalEntryStatus | ||
| 6 | import org.vibeerp.pbc.finance.domain.JournalEntryType | 7 | import org.vibeerp.pbc.finance.domain.JournalEntryType |
| 7 | import java.util.UUID | 8 | import java.util.UUID |
| 8 | 9 | ||
| @@ -22,4 +23,23 @@ interface JournalEntryJpaRepository : JpaRepository<JournalEntry, UUID> { | @@ -22,4 +23,23 @@ interface JournalEntryJpaRepository : JpaRepository<JournalEntry, UUID> { | ||
| 22 | fun findByCode(code: String): JournalEntry? | 23 | fun findByCode(code: String): JournalEntry? |
| 23 | fun findByOrderCode(orderCode: String): List<JournalEntry> | 24 | fun findByOrderCode(orderCode: String): List<JournalEntry> |
| 24 | fun findByType(type: JournalEntryType): List<JournalEntry> | 25 | fun findByType(type: JournalEntryType): List<JournalEntry> |
| 26 | + fun findByStatus(status: JournalEntryStatus): List<JournalEntry> | ||
| 27 | + | ||
| 28 | + /** | ||
| 29 | + * The lookup behind [JournalEntryService.settleByOrderCode] and | ||
| 30 | + * [JournalEntryService.reverseByOrderCode]. Returns null if no | ||
| 31 | + * journal entry exists for the given order code, which is the | ||
| 32 | + * normal case for orders cancelled from DRAFT (no `*ConfirmedEvent` | ||
| 33 | + * was ever published, so no row was ever written) — both transition | ||
| 34 | + * methods treat null as a clean no-op. | ||
| 35 | + * | ||
| 36 | + * Why "first" instead of a list: the v0.16/v0.17 model writes | ||
| 37 | + * exactly one journal entry per order code (one `*ConfirmedEvent` | ||
| 38 | + * → one row, with idempotency on the event id). When the future | ||
| 39 | + * P5.9 build adds separate revenue / cost rows, this query will | ||
| 40 | + * need to grow into a typed lookup (e.g. find the AR row, find | ||
| 41 | + * the revenue row), but that's the right time to make the schema | ||
| 42 | + * decision rather than now. | ||
| 43 | + */ | ||
| 44 | + fun findFirstByOrderCode(orderCode: String): JournalEntry? | ||
| 25 | } | 45 | } |
pbc/pbc-finance/src/test/kotlin/org/vibeerp/pbc/finance/application/JournalEntryServiceTest.kt
| @@ -12,9 +12,14 @@ import org.junit.jupiter.api.BeforeEach | @@ -12,9 +12,14 @@ import org.junit.jupiter.api.BeforeEach | ||
| 12 | import org.junit.jupiter.api.Test | 12 | import org.junit.jupiter.api.Test |
| 13 | import org.vibeerp.api.v1.core.Id | 13 | import org.vibeerp.api.v1.core.Id |
| 14 | import org.vibeerp.api.v1.event.DomainEvent | 14 | import org.vibeerp.api.v1.event.DomainEvent |
| 15 | +import org.vibeerp.api.v1.event.orders.PurchaseOrderCancelledEvent | ||
| 15 | import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent | 16 | import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent |
| 17 | +import org.vibeerp.api.v1.event.orders.PurchaseOrderReceivedEvent | ||
| 18 | +import org.vibeerp.api.v1.event.orders.SalesOrderCancelledEvent | ||
| 16 | import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent | 19 | import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent |
| 20 | +import org.vibeerp.api.v1.event.orders.SalesOrderShippedEvent | ||
| 17 | import org.vibeerp.pbc.finance.domain.JournalEntry | 21 | import org.vibeerp.pbc.finance.domain.JournalEntry |
| 22 | +import org.vibeerp.pbc.finance.domain.JournalEntryStatus | ||
| 18 | import org.vibeerp.pbc.finance.domain.JournalEntryType | 23 | import org.vibeerp.pbc.finance.domain.JournalEntryType |
| 19 | import org.vibeerp.pbc.finance.infrastructure.JournalEntryJpaRepository | 24 | import org.vibeerp.pbc.finance.infrastructure.JournalEntryJpaRepository |
| 20 | import java.math.BigDecimal | 25 | import java.math.BigDecimal |
| @@ -164,6 +169,160 @@ class JournalEntryServiceTest { | @@ -164,6 +169,160 @@ class JournalEntryServiceTest { | ||
| 164 | assertThat(saved[1].code).isEqualTo(purchaseId.toString()) | 169 | assertThat(saved[1].code).isEqualTo(purchaseId.toString()) |
| 165 | } | 170 | } |
| 166 | 171 | ||
| 172 | + // ─── settleFromSalesShipped / settleFromPurchaseReceived ───────── | ||
| 173 | + | ||
| 174 | + private fun postedEntry( | ||
| 175 | + type: JournalEntryType = JournalEntryType.AR, | ||
| 176 | + orderCode: String = "SO-1", | ||
| 177 | + status: JournalEntryStatus = JournalEntryStatus.POSTED, | ||
| 178 | + ): JournalEntry = | ||
| 179 | + JournalEntry( | ||
| 180 | + code = UUID.randomUUID().toString(), | ||
| 181 | + type = type, | ||
| 182 | + partnerCode = "P-1", | ||
| 183 | + orderCode = orderCode, | ||
| 184 | + amount = BigDecimal("10.00"), | ||
| 185 | + currencyCode = "USD", | ||
| 186 | + postedAt = Instant.parse("2026-04-08T00:00:00Z"), | ||
| 187 | + status = status, | ||
| 188 | + ) | ||
| 189 | + | ||
| 190 | + @Test | ||
| 191 | + fun `settleFromSalesShipped flips a POSTED AR row to SETTLED`() { | ||
| 192 | + val entry = postedEntry(type = JournalEntryType.AR, orderCode = "SO-9") | ||
| 193 | + every { entries.findFirstByOrderCode("SO-9") } returns entry | ||
| 194 | + | ||
| 195 | + val result = service.settleFromSalesShipped( | ||
| 196 | + SalesOrderShippedEvent(orderCode = "SO-9", partnerCode = "CUST-1", shippingLocationCode = "WH-MAIN"), | ||
| 197 | + ) | ||
| 198 | + | ||
| 199 | + assertThat(result).isNotNull() | ||
| 200 | + assertThat(entry.status).isEqualTo(JournalEntryStatus.SETTLED) | ||
| 201 | + } | ||
| 202 | + | ||
| 203 | + @Test | ||
| 204 | + fun `settleFromSalesShipped is a no-op for an already-SETTLED entry`() { | ||
| 205 | + val entry = postedEntry(orderCode = "SO-1", status = JournalEntryStatus.SETTLED) | ||
| 206 | + every { entries.findFirstByOrderCode("SO-1") } returns entry | ||
| 207 | + | ||
| 208 | + service.settleFromSalesShipped( | ||
| 209 | + SalesOrderShippedEvent(orderCode = "SO-1", partnerCode = "CUST-1", shippingLocationCode = "WH-MAIN"), | ||
| 210 | + ) | ||
| 211 | + | ||
| 212 | + assertThat(entry.status).isEqualTo(JournalEntryStatus.SETTLED) | ||
| 213 | + verify(exactly = 0) { entries.save(any<JournalEntry>()) } | ||
| 214 | + } | ||
| 215 | + | ||
| 216 | + @Test | ||
| 217 | + fun `settleFromSalesShipped is a no-op when no entry exists for the order code`() { | ||
| 218 | + every { entries.findFirstByOrderCode("SO-MISSING") } returns null | ||
| 219 | + | ||
| 220 | + val result = service.settleFromSalesShipped( | ||
| 221 | + SalesOrderShippedEvent(orderCode = "SO-MISSING", partnerCode = "CUST-1", shippingLocationCode = "WH-MAIN"), | ||
| 222 | + ) | ||
| 223 | + | ||
| 224 | + assertThat(result).isNull() | ||
| 225 | + } | ||
| 226 | + | ||
| 227 | + @Test | ||
| 228 | + fun `settleFromSalesShipped refuses to overwrite a REVERSED entry`() { | ||
| 229 | + val entry = postedEntry(orderCode = "SO-X", status = JournalEntryStatus.REVERSED) | ||
| 230 | + every { entries.findFirstByOrderCode("SO-X") } returns entry | ||
| 231 | + | ||
| 232 | + service.settleFromSalesShipped( | ||
| 233 | + SalesOrderShippedEvent(orderCode = "SO-X", partnerCode = "CUST-1", shippingLocationCode = "WH-MAIN"), | ||
| 234 | + ) | ||
| 235 | + | ||
| 236 | + // The entry stays REVERSED — settle does NOT overwrite a reversal. | ||
| 237 | + assertThat(entry.status).isEqualTo(JournalEntryStatus.REVERSED) | ||
| 238 | + } | ||
| 239 | + | ||
| 240 | + @Test | ||
| 241 | + fun `settleFromPurchaseReceived flips a POSTED AP row to SETTLED`() { | ||
| 242 | + val entry = postedEntry(type = JournalEntryType.AP, orderCode = "PO-9") | ||
| 243 | + every { entries.findFirstByOrderCode("PO-9") } returns entry | ||
| 244 | + | ||
| 245 | + service.settleFromPurchaseReceived( | ||
| 246 | + PurchaseOrderReceivedEvent(orderCode = "PO-9", partnerCode = "SUP-1", receivingLocationCode = "WH-MAIN"), | ||
| 247 | + ) | ||
| 248 | + | ||
| 249 | + assertThat(entry.status).isEqualTo(JournalEntryStatus.SETTLED) | ||
| 250 | + } | ||
| 251 | + | ||
| 252 | + // ─── reverseFromSalesCancelled / reverseFromPurchaseCancelled ──── | ||
| 253 | + | ||
| 254 | + @Test | ||
| 255 | + fun `reverseFromSalesCancelled flips a POSTED AR row to REVERSED`() { | ||
| 256 | + val entry = postedEntry(type = JournalEntryType.AR, orderCode = "SO-CANCEL") | ||
| 257 | + every { entries.findFirstByOrderCode("SO-CANCEL") } returns entry | ||
| 258 | + | ||
| 259 | + service.reverseFromSalesCancelled( | ||
| 260 | + SalesOrderCancelledEvent(orderCode = "SO-CANCEL", partnerCode = "CUST-1"), | ||
| 261 | + ) | ||
| 262 | + | ||
| 263 | + assertThat(entry.status).isEqualTo(JournalEntryStatus.REVERSED) | ||
| 264 | + } | ||
| 265 | + | ||
| 266 | + @Test | ||
| 267 | + fun `reverseFromSalesCancelled is a no-op when the order was cancelled from DRAFT`() { | ||
| 268 | + // No *ConfirmedEvent was ever published for a DRAFT cancel, | ||
| 269 | + // so no journal entry exists. The lookup returns null and | ||
| 270 | + // the call must be a clean no-op (the most common cancel path). | ||
| 271 | + every { entries.findFirstByOrderCode("SO-DRAFT-CANCEL") } returns null | ||
| 272 | + | ||
| 273 | + val result = service.reverseFromSalesCancelled( | ||
| 274 | + SalesOrderCancelledEvent(orderCode = "SO-DRAFT-CANCEL", partnerCode = "CUST-1"), | ||
| 275 | + ) | ||
| 276 | + | ||
| 277 | + assertThat(result).isNull() | ||
| 278 | + } | ||
| 279 | + | ||
| 280 | + @Test | ||
| 281 | + fun `reverseFromSalesCancelled is idempotent for an already-REVERSED entry`() { | ||
| 282 | + val entry = postedEntry(orderCode = "SO-1", status = JournalEntryStatus.REVERSED) | ||
| 283 | + every { entries.findFirstByOrderCode("SO-1") } returns entry | ||
| 284 | + | ||
| 285 | + service.reverseFromSalesCancelled( | ||
| 286 | + SalesOrderCancelledEvent(orderCode = "SO-1", partnerCode = "CUST-1"), | ||
| 287 | + ) | ||
| 288 | + | ||
| 289 | + assertThat(entry.status).isEqualTo(JournalEntryStatus.REVERSED) | ||
| 290 | + } | ||
| 291 | + | ||
| 292 | + @Test | ||
| 293 | + fun `reverseFromSalesCancelled refuses to overwrite a SETTLED entry`() { | ||
| 294 | + val entry = postedEntry(orderCode = "SO-1", status = JournalEntryStatus.SETTLED) | ||
| 295 | + every { entries.findFirstByOrderCode("SO-1") } returns entry | ||
| 296 | + | ||
| 297 | + service.reverseFromSalesCancelled( | ||
| 298 | + SalesOrderCancelledEvent(orderCode = "SO-1", partnerCode = "CUST-1"), | ||
| 299 | + ) | ||
| 300 | + | ||
| 301 | + assertThat(entry.status).isEqualTo(JournalEntryStatus.SETTLED) | ||
| 302 | + } | ||
| 303 | + | ||
| 304 | + @Test | ||
| 305 | + fun `reverseFromPurchaseCancelled flips a POSTED AP row to REVERSED`() { | ||
| 306 | + val entry = postedEntry(type = JournalEntryType.AP, orderCode = "PO-CANCEL") | ||
| 307 | + every { entries.findFirstByOrderCode("PO-CANCEL") } returns entry | ||
| 308 | + | ||
| 309 | + service.reverseFromPurchaseCancelled( | ||
| 310 | + PurchaseOrderCancelledEvent(orderCode = "PO-CANCEL", partnerCode = "SUP-1"), | ||
| 311 | + ) | ||
| 312 | + | ||
| 313 | + assertThat(entry.status).isEqualTo(JournalEntryStatus.REVERSED) | ||
| 314 | + } | ||
| 315 | + | ||
| 316 | + @Test | ||
| 317 | + fun `recordSalesConfirmed writes new entries with default POSTED status`() { | ||
| 318 | + val saved = slot<JournalEntry>() | ||
| 319 | + every { entries.save(capture(saved)) } answers { saved.captured } | ||
| 320 | + | ||
| 321 | + service.recordSalesConfirmed(salesEvent()) | ||
| 322 | + | ||
| 323 | + assertThat(saved.captured.status).isEqualTo(JournalEntryStatus.POSTED) | ||
| 324 | + } | ||
| 325 | + | ||
| 167 | // ─── compile-time guard: events still implement DomainEvent ────── | 326 | // ─── compile-time guard: events still implement DomainEvent ────── |
| 168 | 327 | ||
| 169 | @Test | 328 | @Test |
pbc/pbc-finance/src/test/kotlin/org/vibeerp/pbc/finance/event/OrderEventSubscribersTest.kt
| @@ -7,14 +7,18 @@ import io.mockk.verify | @@ -7,14 +7,18 @@ import io.mockk.verify | ||
| 7 | import org.junit.jupiter.api.Test | 7 | import org.junit.jupiter.api.Test |
| 8 | import org.vibeerp.api.v1.event.EventBus | 8 | import org.vibeerp.api.v1.event.EventBus |
| 9 | import org.vibeerp.api.v1.event.EventListener | 9 | import org.vibeerp.api.v1.event.EventListener |
| 10 | +import org.vibeerp.api.v1.event.orders.PurchaseOrderCancelledEvent | ||
| 10 | import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent | 11 | import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent |
| 12 | +import org.vibeerp.api.v1.event.orders.PurchaseOrderReceivedEvent | ||
| 13 | +import org.vibeerp.api.v1.event.orders.SalesOrderCancelledEvent | ||
| 11 | import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent | 14 | import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent |
| 15 | +import org.vibeerp.api.v1.event.orders.SalesOrderShippedEvent | ||
| 12 | import org.vibeerp.pbc.finance.application.JournalEntryService | 16 | import org.vibeerp.pbc.finance.application.JournalEntryService |
| 13 | 17 | ||
| 14 | class OrderEventSubscribersTest { | 18 | class OrderEventSubscribersTest { |
| 15 | 19 | ||
| 16 | @Test | 20 | @Test |
| 17 | - fun `subscribe registers exactly one typed listener for each of the two events`() { | 21 | + fun `subscribe registers exactly one typed listener for each of the six lifecycle events`() { |
| 18 | val eventBus = mockk<EventBus>(relaxed = true) | 22 | val eventBus = mockk<EventBus>(relaxed = true) |
| 19 | val journal = mockk<JournalEntryService>(relaxed = true) | 23 | val journal = mockk<JournalEntryService>(relaxed = true) |
| 20 | 24 | ||
| @@ -23,6 +27,10 @@ class OrderEventSubscribersTest { | @@ -23,6 +27,10 @@ class OrderEventSubscribersTest { | ||
| 23 | 27 | ||
| 24 | verify(exactly = 1) { eventBus.subscribe(SalesOrderConfirmedEvent::class.java, any<EventListener<SalesOrderConfirmedEvent>>()) } | 28 | verify(exactly = 1) { eventBus.subscribe(SalesOrderConfirmedEvent::class.java, any<EventListener<SalesOrderConfirmedEvent>>()) } |
| 25 | verify(exactly = 1) { eventBus.subscribe(PurchaseOrderConfirmedEvent::class.java, any<EventListener<PurchaseOrderConfirmedEvent>>()) } | 29 | verify(exactly = 1) { eventBus.subscribe(PurchaseOrderConfirmedEvent::class.java, any<EventListener<PurchaseOrderConfirmedEvent>>()) } |
| 30 | + verify(exactly = 1) { eventBus.subscribe(SalesOrderShippedEvent::class.java, any<EventListener<SalesOrderShippedEvent>>()) } | ||
| 31 | + verify(exactly = 1) { eventBus.subscribe(PurchaseOrderReceivedEvent::class.java, any<EventListener<PurchaseOrderReceivedEvent>>()) } | ||
| 32 | + verify(exactly = 1) { eventBus.subscribe(SalesOrderCancelledEvent::class.java, any<EventListener<SalesOrderCancelledEvent>>()) } | ||
| 33 | + verify(exactly = 1) { eventBus.subscribe(PurchaseOrderCancelledEvent::class.java, any<EventListener<PurchaseOrderCancelledEvent>>()) } | ||
| 26 | } | 34 | } |
| 27 | 35 | ||
| 28 | @Test | 36 | @Test |
| @@ -68,4 +76,34 @@ class OrderEventSubscribersTest { | @@ -68,4 +76,34 @@ class OrderEventSubscribersTest { | ||
| 68 | 76 | ||
| 69 | verify(exactly = 1) { journal.recordPurchaseConfirmed(event) } | 77 | verify(exactly = 1) { journal.recordPurchaseConfirmed(event) } |
| 70 | } | 78 | } |
| 79 | + | ||
| 80 | + @Test | ||
| 81 | + fun `shipped, received, and cancelled listeners forward to the correct service methods`() { | ||
| 82 | + val eventBus = mockk<EventBus>(relaxed = true) | ||
| 83 | + val journal = mockk<JournalEntryService>(relaxed = true) | ||
| 84 | + val shipped = slot<EventListener<SalesOrderShippedEvent>>() | ||
| 85 | + val received = slot<EventListener<PurchaseOrderReceivedEvent>>() | ||
| 86 | + val salesCancelled = slot<EventListener<SalesOrderCancelledEvent>>() | ||
| 87 | + val purchCancelled = slot<EventListener<PurchaseOrderCancelledEvent>>() | ||
| 88 | + every { eventBus.subscribe(SalesOrderShippedEvent::class.java, capture(shipped)) } answers { mockk(relaxed = true) } | ||
| 89 | + every { eventBus.subscribe(PurchaseOrderReceivedEvent::class.java, capture(received)) } answers { mockk(relaxed = true) } | ||
| 90 | + every { eventBus.subscribe(SalesOrderCancelledEvent::class.java, capture(salesCancelled)) } answers { mockk(relaxed = true) } | ||
| 91 | + every { eventBus.subscribe(PurchaseOrderCancelledEvent::class.java, capture(purchCancelled)) } answers { mockk(relaxed = true) } | ||
| 92 | + | ||
| 93 | + OrderEventSubscribers(eventBus, journal).subscribe() | ||
| 94 | + | ||
| 95 | + val shipEvent = SalesOrderShippedEvent(orderCode = "SO-1", partnerCode = "CUST-1", shippingLocationCode = "WH-MAIN") | ||
| 96 | + val recvEvent = PurchaseOrderReceivedEvent(orderCode = "PO-1", partnerCode = "SUP-1", receivingLocationCode = "WH-MAIN") | ||
| 97 | + val soCancelEvent = SalesOrderCancelledEvent(orderCode = "SO-2", partnerCode = "CUST-1") | ||
| 98 | + val poCancelEvent = PurchaseOrderCancelledEvent(orderCode = "PO-2", partnerCode = "SUP-1") | ||
| 99 | + shipped.captured.handle(shipEvent) | ||
| 100 | + received.captured.handle(recvEvent) | ||
| 101 | + salesCancelled.captured.handle(soCancelEvent) | ||
| 102 | + purchCancelled.captured.handle(poCancelEvent) | ||
| 103 | + | ||
| 104 | + verify(exactly = 1) { journal.settleFromSalesShipped(shipEvent) } | ||
| 105 | + verify(exactly = 1) { journal.settleFromPurchaseReceived(recvEvent) } | ||
| 106 | + verify(exactly = 1) { journal.reverseFromSalesCancelled(soCancelEvent) } | ||
| 107 | + verify(exactly = 1) { journal.reverseFromPurchaseCancelled(poCancelEvent) } | ||
| 108 | + } | ||
| 71 | } | 109 | } |