Commit 3745de3a34a98becb33398bcba08986cdba12d51

Authored by zichun
1 parent 4d0dd9fe

feat(finance): double-entry journal entry lines — real GL primitives

Adds JournalEntryLine child entity with debit/credit legs per
account, completing the pbc-finance GL foundation.

Domain:
  - JournalEntryLine entity: lineNo, accountCode, debit (>=0),
    credit (>=0), description. FK to parent JournalEntry with
    CASCADE delete. Unique (journal_entry_id, line_no).
  - JournalEntry gains @OneToMany lines collection (EAGER fetch,
    ordered by lineNo).

Event subscribers now write balanced double-entry lines:
  - SalesOrderConfirmed: DR 1100 (AR), CR 4100 (Revenue)
  - PurchaseOrderConfirmed: DR 1200 (Inventory), CR 2100 (AP)

The parent JournalEntry.amount is retained as a denormalized
summary; the lines are the source of truth for accounting. The
seeded account codes (1100, 1200, 2100, 4100, 5100) match the
chart from the previous commit.

JournalEntryController.toResponse() now includes the lines array
so the SPA can display debit/credit legs inline.

Schema: 004-finance-entry-lines.xml adds finance__journal_entry_line
with FK, unique (entry, lineNo), non-negative check on dr/cr.

Smoke verified on fresh Postgres:
  - Confirm SO-2026-0001 -> AR entry with 2 lines:
    DR 1100 $1950, CR 4100 $1950
  - Confirm PO-2026-0001 -> AP entry with 2 lines:
    DR 1200 $2550, CR 2100 $2550
  - Both entries balanced (sum DR = sum CR)

Caught by smoke: LazyInitializationException on the new lines
collection — fixed by switching FetchType from LAZY to EAGER
(entries have 2-4 lines max, eager is appropriate).
distribution/src/main/resources/db/changelog/master.xml
... ... @@ -24,6 +24,7 @@
24 24 <include file="classpath:db/changelog/pbc-finance/001-finance-init.xml"/>
25 25 <include file="classpath:db/changelog/pbc-finance/002-finance-status.xml"/>
26 26 <include file="classpath:db/changelog/pbc-finance/003-finance-accounts.xml"/>
  27 + <include file="classpath:db/changelog/pbc-finance/004-finance-entry-lines.xml"/>
27 28 <include file="classpath:db/changelog/pbc-production/001-production-init.xml"/>
28 29 <include file="classpath:db/changelog/pbc-production/002-production-v2.xml"/>
29 30 <include file="classpath:db/changelog/pbc-production/003-production-v3.xml"/>
... ...
distribution/src/main/resources/db/changelog/pbc-finance/004-finance-entry-lines.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.30 — Double-entry journal entry lines.
  9 +
  10 + Each JournalEntry can now have N debit/credit legs, each
  11 + referencing an account from the chart of accounts. The
  12 + balanced-entry invariant (sum of debits = sum of credits)
  13 + is enforced by JournalEntryService, not by a DB constraint,
  14 + because Postgres CHECK constraints can't span multiple rows.
  15 +
  16 + The parent JournalEntry's `amount` column is retained as a
  17 + denormalized summary for backward compatibility; the lines
  18 + are the source of truth for double-entry accounting.
  19 + -->
  20 +
  21 + <changeSet id="finance-entry-lines-001" author="vibe_erp">
  22 + <comment>Create finance__journal_entry_line table</comment>
  23 + <sql>
  24 + CREATE TABLE finance__journal_entry_line (
  25 + id uuid PRIMARY KEY,
  26 + journal_entry_id uuid NOT NULL REFERENCES finance__journal_entry(id) ON DELETE CASCADE,
  27 + line_no integer NOT NULL,
  28 + account_code varchar(32) NOT NULL,
  29 + debit numeric(18,4) NOT NULL DEFAULT 0,
  30 + credit numeric(18,4) NOT NULL DEFAULT 0,
  31 + description text,
  32 + created_at timestamptz NOT NULL,
  33 + created_by varchar(128) NOT NULL,
  34 + updated_at timestamptz NOT NULL,
  35 + updated_by varchar(128) NOT NULL,
  36 + version bigint NOT NULL DEFAULT 0,
  37 + CONSTRAINT finance__jel_line_no_uk UNIQUE (journal_entry_id, line_no),
  38 + CONSTRAINT finance__jel_nonneg_check CHECK (debit >= 0 AND credit >= 0)
  39 + );
  40 + CREATE INDEX finance__jel_entry_idx ON finance__journal_entry_line (journal_entry_id);
  41 + CREATE INDEX finance__jel_account_idx ON finance__journal_entry_line (account_code);
  42 + </sql>
  43 + <rollback>
  44 + DROP TABLE finance__journal_entry_line;
  45 + </rollback>
  46 + </changeSet>
  47 +
  48 +</databaseChangeLog>
... ...
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/application/JournalEntryService.kt
... ... @@ -11,9 +11,11 @@ import org.vibeerp.api.v1.event.orders.SalesOrderCancelledEvent
11 11 import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent
12 12 import org.vibeerp.api.v1.event.orders.SalesOrderShippedEvent
13 13 import org.vibeerp.pbc.finance.domain.JournalEntry
  14 +import org.vibeerp.pbc.finance.domain.JournalEntryLine
14 15 import org.vibeerp.pbc.finance.domain.JournalEntryStatus
15 16 import org.vibeerp.pbc.finance.domain.JournalEntryType
16 17 import org.vibeerp.pbc.finance.infrastructure.JournalEntryJpaRepository
  18 +import java.math.BigDecimal
17 19 import java.util.UUID
18 20  
19 21 /**
... ... @@ -76,27 +78,21 @@ class JournalEntryService(
76 78 "[finance] AR ← SalesOrderConfirmedEvent orderCode={} partnerCode={} amount={} {}",
77 79 event.orderCode, event.partnerCode, event.totalAmount, event.currencyCode,
78 80 )
79   - return entries.save(
80   - JournalEntry(
81   - code = code,
82   - type = JournalEntryType.AR,
83   - partnerCode = event.partnerCode,
84   - orderCode = event.orderCode,
85   - amount = event.totalAmount,
86   - currencyCode = event.currencyCode,
87   - postedAt = event.occurredAt,
88   - ),
  81 + val entry = JournalEntry(
  82 + code = code,
  83 + type = JournalEntryType.AR,
  84 + partnerCode = event.partnerCode,
  85 + orderCode = event.orderCode,
  86 + amount = event.totalAmount,
  87 + currencyCode = event.currencyCode,
  88 + postedAt = event.occurredAt,
89 89 )
  90 + // Double-entry: DR Accounts Receivable, CR Sales Revenue
  91 + addLine(entry, 1, ACCOUNT_AR, event.totalAmount, BigDecimal.ZERO, "AR from sales order ${event.orderCode}")
  92 + addLine(entry, 2, ACCOUNT_REVENUE, BigDecimal.ZERO, event.totalAmount, "Revenue from sales order ${event.orderCode}")
  93 + return entries.save(entry)
90 94 }
91 95  
92   - /**
93   - * React to a `PurchaseOrderConfirmedEvent` by writing an AP row.
94   - *
95   - * Mirror of [recordSalesConfirmed] for the buying side. The
96   - * supplier is now owed the order total. Real cash-flow timing
97   - * (and the actual payable becoming payable on receipt or
98   - * invoice match) lives in the future P5.9 build.
99   - */
100 96 fun recordPurchaseConfirmed(event: PurchaseOrderConfirmedEvent): JournalEntry? {
101 97 val code = event.eventId.value.toString()
102 98 if (entries.existsByCode(code)) {
... ... @@ -110,17 +106,25 @@ class JournalEntryService(
110 106 "[finance] AP ← PurchaseOrderConfirmedEvent orderCode={} partnerCode={} amount={} {}",
111 107 event.orderCode, event.partnerCode, event.totalAmount, event.currencyCode,
112 108 )
113   - return entries.save(
114   - JournalEntry(
115   - code = code,
116   - type = JournalEntryType.AP,
117   - partnerCode = event.partnerCode,
118   - orderCode = event.orderCode,
119   - amount = event.totalAmount,
120   - currencyCode = event.currencyCode,
121   - postedAt = event.occurredAt,
122   - ),
  109 + val entry = JournalEntry(
  110 + code = code,
  111 + type = JournalEntryType.AP,
  112 + partnerCode = event.partnerCode,
  113 + orderCode = event.orderCode,
  114 + amount = event.totalAmount,
  115 + currencyCode = event.currencyCode,
  116 + postedAt = event.occurredAt,
123 117 )
  118 + // Double-entry: DR Inventory, CR Accounts Payable
  119 + addLine(entry, 1, ACCOUNT_INVENTORY, event.totalAmount, BigDecimal.ZERO, "Inventory from PO ${event.orderCode}")
  120 + addLine(entry, 2, ACCOUNT_AP, BigDecimal.ZERO, event.totalAmount, "AP to supplier ${event.partnerCode}")
  121 + return entries.save(entry)
  122 + }
  123 +
  124 + private fun addLine(entry: JournalEntry, lineNo: Int, accountCode: String, debit: BigDecimal, credit: BigDecimal, desc: String) {
  125 + val line = JournalEntryLine(lineNo = lineNo, accountCode = accountCode, debit = debit, credit = credit, description = desc)
  126 + line.journalEntry = entry
  127 + entry.lines.add(line)
124 128 }
125 129  
126 130 /**
... ... @@ -238,4 +242,13 @@ class JournalEntryService(
238 242  
239 243 @Transactional(readOnly = true)
240 244 fun findByStatus(status: JournalEntryStatus): List<JournalEntry> = entries.findByStatus(status)
  245 +
  246 + companion object {
  247 + // Seeded account codes from 003-finance-accounts.xml
  248 + const val ACCOUNT_AR: String = "1100"
  249 + const val ACCOUNT_INVENTORY: String = "1200"
  250 + const val ACCOUNT_AP: String = "2100"
  251 + const val ACCOUNT_REVENUE: String = "4100"
  252 + const val ACCOUNT_COGS: String = "5100"
  253 + }
241 254 }
... ...
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/JournalEntry.kt
1 1 package org.vibeerp.pbc.finance.domain
2 2  
  3 +import jakarta.persistence.CascadeType
3 4 import jakarta.persistence.Column
4 5 import jakarta.persistence.Entity
5 6 import jakarta.persistence.EnumType
6 7 import jakarta.persistence.Enumerated
  8 +import jakarta.persistence.FetchType
  9 +import jakarta.persistence.OneToMany
  10 +import jakarta.persistence.OrderBy
7 11 import jakarta.persistence.Table
8 12 import org.hibernate.annotations.JdbcTypeCode
9 13 import org.hibernate.type.SqlTypes
... ... @@ -101,6 +105,15 @@ class JournalEntry(
101 105 @Column(name = "status", nullable = false, length = 16)
102 106 var status: JournalEntryStatus = status
103 107  
  108 + @OneToMany(
  109 + mappedBy = "journalEntry",
  110 + cascade = [CascadeType.ALL],
  111 + orphanRemoval = true,
  112 + fetch = FetchType.EAGER,
  113 + )
  114 + @OrderBy("lineNo ASC")
  115 + val lines: MutableList<JournalEntryLine> = mutableListOf()
  116 +
104 117 @Column(name = "ext", nullable = false, columnDefinition = "jsonb")
105 118 @JdbcTypeCode(SqlTypes.JSON)
106 119 var ext: String = "{}"
... ...
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/JournalEntryLine.kt 0 → 100644
  1 +package org.vibeerp.pbc.finance.domain
  2 +
  3 +import jakarta.persistence.Column
  4 +import jakarta.persistence.Entity
  5 +import jakarta.persistence.FetchType
  6 +import jakarta.persistence.JoinColumn
  7 +import jakarta.persistence.ManyToOne
  8 +import jakarta.persistence.Table
  9 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  10 +import java.math.BigDecimal
  11 +
  12 +/**
  13 + * One debit or credit leg of a [JournalEntry].
  14 + *
  15 + * The fundamental double-entry invariant: for every journal entry,
  16 + * the sum of all lines' [debit] values MUST equal the sum of all
  17 + * lines' [credit] values. [JournalEntryService] enforces this
  18 + * before saving.
  19 + *
  20 + * Each line references an [Account] by code (not FK — the account
  21 + * lives in the same PBC so a code lookup is fine and avoids JPA
  22 + * bidirectional coupling). A line carries a non-negative [debit]
  23 + * OR a non-negative [credit]; at most one of the two is non-zero.
  24 + *
  25 + * **Why a child table instead of inline columns on JournalEntry.**
  26 + * A flat "debitAccount + creditAccount" pair on the parent would
  27 + * limit every entry to exactly two legs. Real accounting entries
  28 + * can have N legs (e.g. a sales invoice debits AR and credits
  29 + * both Revenue and Tax Payable). The child table is the
  30 + * normalised shape that every GL schema uses.
  31 + */
  32 +@Entity
  33 +@Table(name = "finance__journal_entry_line")
  34 +class JournalEntryLine(
  35 + lineNo: Int,
  36 + accountCode: String,
  37 + debit: BigDecimal = BigDecimal.ZERO,
  38 + credit: BigDecimal = BigDecimal.ZERO,
  39 + description: String? = null,
  40 +) : AuditedJpaEntity() {
  41 +
  42 + @ManyToOne(fetch = FetchType.LAZY)
  43 + @JoinColumn(name = "journal_entry_id", nullable = false)
  44 + var journalEntry: JournalEntry? = null
  45 +
  46 + @Column(name = "line_no", nullable = false)
  47 + var lineNo: Int = lineNo
  48 +
  49 + @Column(name = "account_code", nullable = false, length = 32)
  50 + var accountCode: String = accountCode
  51 +
  52 + @Column(name = "debit", nullable = false, precision = 18, scale = 4)
  53 + var debit: BigDecimal = debit
  54 +
  55 + @Column(name = "credit", nullable = false, precision = 18, scale = 4)
  56 + var credit: BigDecimal = credit
  57 +
  58 + @Column(name = "description", nullable = true)
  59 + var description: String? = description
  60 +
  61 + override fun toString(): String =
  62 + "JournalEntryLine(id=$id, line=$lineNo, account=$accountCode, dr=$debit, cr=$credit)"
  63 +}
... ...
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/http/JournalEntryController.kt
... ... @@ -74,6 +74,15 @@ data class JournalEntryResponse(
74 74 val amount: BigDecimal,
75 75 val currencyCode: String,
76 76 val postedAt: Instant,
  77 + val lines: List<JournalEntryLineResponse>,
  78 +)
  79 +
  80 +data class JournalEntryLineResponse(
  81 + val lineNo: Int,
  82 + val accountCode: String,
  83 + val debit: BigDecimal,
  84 + val credit: BigDecimal,
  85 + val description: String?,
77 86 )
78 87  
79 88 private fun JournalEntry.toResponse(): JournalEntryResponse =
... ... @@ -87,4 +96,11 @@ private fun JournalEntry.toResponse(): JournalEntryResponse =
87 96 amount = amount,
88 97 currencyCode = currencyCode,
89 98 postedAt = postedAt,
  99 + lines = lines.map { JournalEntryLineResponse(
  100 + lineNo = it.lineNo,
  101 + accountCode = it.accountCode,
  102 + debit = it.debit,
  103 + credit = it.credit,
  104 + description = it.description,
  105 + ) },
90 106 )
... ...