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 | 96 | **Foundation complete; first business surface in place.** As of the latest commit: |
| 97 | 97 | |
| 98 | 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 | 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 | 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 | 103 | - **Reference printing-shop plug-in** owns its own DB schema, CRUDs plates and ink recipes via REST through `context.jdbc`, ships its own metadata YAML, and registers 7 HTTP endpoints. It is the executable acceptance test for the framework. |
| 104 | 104 | - **Package root** is `org.vibeerp`. |
| 105 | 105 | - **Build commands:** `./gradlew build` (full), `./gradlew test` (unit tests), `./gradlew :distribution:bootRun` (run dev profile, stages plug-in JAR automatically), `docker compose up -d db` (Postgres). The bootstrap admin password is printed to the application logs on first boot. | ... | ... |
PROGRESS.md
| ... | ... | @@ -10,19 +10,19 @@ |
| 10 | 10 | |
| 11 | 11 | | | | |
| 12 | 12 | |---|---| |
| 13 | -| **Latest version** | v0.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 | 15 | | **Repo** | https://github.com/reporkey/vibe-erp | |
| 16 | 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 | 19 | | **Real PBCs implemented** | 7 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`) | |
| 20 | 20 | | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | |
| 21 | 21 | | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. | |
| 22 | 22 | |
| 23 | 23 | ## Current stage |
| 24 | 24 | |
| 25 | -**Foundation complete; Tier 1 customization live; authorization enforced; full buy-and-sell loop closed; event-driven cross-PBC integration live 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 | 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 | 77 | ## Building |
| 78 | 78 | |
| 79 | 79 | ```bash |
| 80 | -# Build everything (compiles 17 modules, runs 201 unit tests) | |
| 80 | +# Build everything (compiles 17 modules, runs 213 unit tests) | |
| 81 | 81 | ./gradlew build |
| 82 | 82 | |
| 83 | 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 | 97 | | | | |
| 98 | 98 | |---|---| |
| 99 | 99 | | Modules | 17 | |
| 100 | -| Unit tests | 201, all green | | |
| 100 | +| Unit tests | 213, all green | | |
| 101 | 101 | | Real PBCs | 7 of 10 | |
| 102 | 102 | | Cross-cutting services live | 9 | |
| 103 | 103 | | Plug-ins serving HTTP | 1 (reference printing-shop) | | ... | ... |
distribution/src/main/resources/db/changelog/master.xml
| ... | ... | @@ -21,4 +21,5 @@ |
| 21 | 21 | <include file="classpath:db/changelog/pbc-orders-sales/001-orders-sales-init.xml"/> |
| 22 | 22 | <include file="classpath:db/changelog/pbc-orders-purchase/001-orders-purchase-init.xml"/> |
| 23 | 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 | 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 | 4 | import org.springframework.stereotype.Service |
| 5 | 5 | import org.springframework.transaction.annotation.Propagation |
| 6 | 6 | import org.springframework.transaction.annotation.Transactional |
| 7 | +import org.vibeerp.api.v1.event.orders.PurchaseOrderCancelledEvent | |
| 7 | 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 | 11 | import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent |
| 12 | +import org.vibeerp.api.v1.event.orders.SalesOrderShippedEvent | |
| 9 | 13 | import org.vibeerp.pbc.finance.domain.JournalEntry |
| 14 | +import org.vibeerp.pbc.finance.domain.JournalEntryStatus | |
| 10 | 15 | import org.vibeerp.pbc.finance.domain.JournalEntryType |
| 11 | 16 | import org.vibeerp.pbc.finance.infrastructure.JournalEntryJpaRepository |
| 12 | 17 | import java.util.UUID |
| ... | ... | @@ -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 | 227 | @Transactional(readOnly = true) |
| 122 | 228 | fun list(): List<JournalEntry> = entries.findAll() |
| 123 | 229 | |
| ... | ... | @@ -129,4 +235,7 @@ class JournalEntryService( |
| 129 | 235 | |
| 130 | 236 | @Transactional(readOnly = true) |
| 131 | 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 | 51 | amount: BigDecimal, |
| 52 | 52 | currencyCode: String, |
| 53 | 53 | postedAt: Instant, |
| 54 | + status: JournalEntryStatus = JournalEntryStatus.POSTED, | |
| 54 | 55 | ) : AuditedJpaEntity() { |
| 55 | 56 | |
| 56 | 57 | @Column(name = "code", nullable = false, length = 64) |
| ... | ... | @@ -75,12 +76,51 @@ class JournalEntry( |
| 75 | 76 | @Column(name = "posted_at", nullable = false) |
| 76 | 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 | 104 | @Column(name = "ext", nullable = false, columnDefinition = "jsonb") |
| 79 | 105 | @JdbcTypeCode(SqlTypes.JSON) |
| 80 | 106 | var ext: String = "{}" |
| 81 | 107 | |
| 82 | 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 | 5 | import org.springframework.stereotype.Component |
| 6 | 6 | import org.vibeerp.api.v1.event.EventBus |
| 7 | 7 | import org.vibeerp.api.v1.event.EventListener |
| 8 | +import org.vibeerp.api.v1.event.orders.PurchaseOrderCancelledEvent | |
| 8 | 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 | 12 | import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent |
| 13 | +import org.vibeerp.api.v1.event.orders.SalesOrderShippedEvent | |
| 10 | 14 | import org.vibeerp.pbc.finance.application.JournalEntryService |
| 11 | 15 | |
| 12 | 16 | /** |
| ... | ... | @@ -55,6 +59,7 @@ class OrderEventSubscribers( |
| 55 | 59 | |
| 56 | 60 | @PostConstruct |
| 57 | 61 | fun subscribe() { |
| 62 | + // Posting (create) — confirm events. | |
| 58 | 63 | eventBus.subscribe( |
| 59 | 64 | SalesOrderConfirmedEvent::class.java, |
| 60 | 65 | EventListener { event -> journal.recordSalesConfirmed(event) }, |
| ... | ... | @@ -63,8 +68,30 @@ class OrderEventSubscribers( |
| 63 | 68 | PurchaseOrderConfirmedEvent::class.java, |
| 64 | 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 | 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 | 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 | 8 | import org.springframework.web.bind.annotation.RestController |
| 9 | 9 | import org.vibeerp.pbc.finance.application.JournalEntryService |
| 10 | 10 | import org.vibeerp.pbc.finance.domain.JournalEntry |
| 11 | +import org.vibeerp.pbc.finance.domain.JournalEntryStatus | |
| 11 | 12 | import org.vibeerp.pbc.finance.domain.JournalEntryType |
| 12 | 13 | import org.vibeerp.platform.security.authz.RequirePermission |
| 13 | 14 | import java.math.BigDecimal |
| ... | ... | @@ -44,10 +45,12 @@ class JournalEntryController( |
| 44 | 45 | fun list( |
| 45 | 46 | @RequestParam(required = false) orderCode: String?, |
| 46 | 47 | @RequestParam(required = false) type: JournalEntryType?, |
| 48 | + @RequestParam(required = false) status: JournalEntryStatus?, | |
| 47 | 49 | ): List<JournalEntryResponse> { |
| 48 | 50 | val rows = when { |
| 49 | 51 | orderCode != null -> journalEntryService.findByOrderCode(orderCode) |
| 50 | 52 | type != null -> journalEntryService.findByType(type) |
| 53 | + status != null -> journalEntryService.findByStatus(status) | |
| 51 | 54 | else -> journalEntryService.list() |
| 52 | 55 | } |
| 53 | 56 | return rows.map { it.toResponse() } |
| ... | ... | @@ -65,6 +68,7 @@ data class JournalEntryResponse( |
| 65 | 68 | val id: UUID, |
| 66 | 69 | val code: String, |
| 67 | 70 | val type: JournalEntryType, |
| 71 | + val status: JournalEntryStatus, | |
| 68 | 72 | val partnerCode: String, |
| 69 | 73 | val orderCode: String, |
| 70 | 74 | val amount: BigDecimal, |
| ... | ... | @@ -77,6 +81,7 @@ private fun JournalEntry.toResponse(): JournalEntryResponse = |
| 77 | 81 | id = id, |
| 78 | 82 | code = code, |
| 79 | 83 | type = type, |
| 84 | + status = status, | |
| 80 | 85 | partnerCode = partnerCode, |
| 81 | 86 | orderCode = orderCode, |
| 82 | 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 | 3 | import org.springframework.data.jpa.repository.JpaRepository |
| 4 | 4 | import org.springframework.stereotype.Repository |
| 5 | 5 | import org.vibeerp.pbc.finance.domain.JournalEntry |
| 6 | +import org.vibeerp.pbc.finance.domain.JournalEntryStatus | |
| 6 | 7 | import org.vibeerp.pbc.finance.domain.JournalEntryType |
| 7 | 8 | import java.util.UUID |
| 8 | 9 | |
| ... | ... | @@ -22,4 +23,23 @@ interface JournalEntryJpaRepository : JpaRepository<JournalEntry, UUID> { |
| 22 | 23 | fun findByCode(code: String): JournalEntry? |
| 23 | 24 | fun findByOrderCode(orderCode: String): List<JournalEntry> |
| 24 | 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 | 12 | import org.junit.jupiter.api.Test |
| 13 | 13 | import org.vibeerp.api.v1.core.Id |
| 14 | 14 | import org.vibeerp.api.v1.event.DomainEvent |
| 15 | +import org.vibeerp.api.v1.event.orders.PurchaseOrderCancelledEvent | |
| 15 | 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 | 19 | import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent |
| 20 | +import org.vibeerp.api.v1.event.orders.SalesOrderShippedEvent | |
| 17 | 21 | import org.vibeerp.pbc.finance.domain.JournalEntry |
| 22 | +import org.vibeerp.pbc.finance.domain.JournalEntryStatus | |
| 18 | 23 | import org.vibeerp.pbc.finance.domain.JournalEntryType |
| 19 | 24 | import org.vibeerp.pbc.finance.infrastructure.JournalEntryJpaRepository |
| 20 | 25 | import java.math.BigDecimal |
| ... | ... | @@ -164,6 +169,160 @@ class JournalEntryServiceTest { |
| 164 | 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 | 326 | // ─── compile-time guard: events still implement DomainEvent ────── |
| 168 | 327 | |
| 169 | 328 | @Test | ... | ... |
pbc/pbc-finance/src/test/kotlin/org/vibeerp/pbc/finance/event/OrderEventSubscribersTest.kt
| ... | ... | @@ -7,14 +7,18 @@ import io.mockk.verify |
| 7 | 7 | import org.junit.jupiter.api.Test |
| 8 | 8 | import org.vibeerp.api.v1.event.EventBus |
| 9 | 9 | import org.vibeerp.api.v1.event.EventListener |
| 10 | +import org.vibeerp.api.v1.event.orders.PurchaseOrderCancelledEvent | |
| 10 | 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 | 14 | import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent |
| 15 | +import org.vibeerp.api.v1.event.orders.SalesOrderShippedEvent | |
| 12 | 16 | import org.vibeerp.pbc.finance.application.JournalEntryService |
| 13 | 17 | |
| 14 | 18 | class OrderEventSubscribersTest { |
| 15 | 19 | |
| 16 | 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 | 22 | val eventBus = mockk<EventBus>(relaxed = true) |
| 19 | 23 | val journal = mockk<JournalEntryService>(relaxed = true) |
| 20 | 24 | |
| ... | ... | @@ -23,6 +27,10 @@ class OrderEventSubscribersTest { |
| 23 | 27 | |
| 24 | 28 | verify(exactly = 1) { eventBus.subscribe(SalesOrderConfirmedEvent::class.java, any<EventListener<SalesOrderConfirmedEvent>>()) } |
| 25 | 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 | 36 | @Test |
| ... | ... | @@ -68,4 +76,34 @@ class OrderEventSubscribersTest { |
| 68 | 76 | |
| 69 | 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 | } | ... | ... |