Commit 0e9736c9205fda276da95d6b4c6dfad6907155c3

Authored by zichun
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.
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&lt;JournalEntry, UUID&gt; {
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 }
... ...