Commit 3745de3a34a98becb33398bcba08986cdba12d51
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).
Showing
6 changed files
with
182 additions
and
28 deletions
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 | ) | ... | ... |
-
mentioned in commit 1f6482be