From 3745de3a34a98becb33398bcba08986cdba12d51 Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 10 Apr 2026 11:16:37 +0800 Subject: [PATCH] feat(finance): double-entry journal entry lines — real GL primitives --- distribution/src/main/resources/db/changelog/master.xml | 1 + distribution/src/main/resources/db/changelog/pbc-finance/004-finance-entry-lines.xml | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/application/JournalEntryService.kt | 69 +++++++++++++++++++++++++++++++++++++++++---------------------------- pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/JournalEntry.kt | 13 +++++++++++++ pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/JournalEntryLine.kt | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/http/JournalEntryController.kt | 16 ++++++++++++++++ 6 files changed, 182 insertions(+), 28 deletions(-) create mode 100644 distribution/src/main/resources/db/changelog/pbc-finance/004-finance-entry-lines.xml create mode 100644 pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/JournalEntryLine.kt diff --git a/distribution/src/main/resources/db/changelog/master.xml b/distribution/src/main/resources/db/changelog/master.xml index d84ea5f..759a4eb 100644 --- a/distribution/src/main/resources/db/changelog/master.xml +++ b/distribution/src/main/resources/db/changelog/master.xml @@ -24,6 +24,7 @@ + diff --git a/distribution/src/main/resources/db/changelog/pbc-finance/004-finance-entry-lines.xml b/distribution/src/main/resources/db/changelog/pbc-finance/004-finance-entry-lines.xml new file mode 100644 index 0000000..0725a97 --- /dev/null +++ b/distribution/src/main/resources/db/changelog/pbc-finance/004-finance-entry-lines.xml @@ -0,0 +1,48 @@ + + + + + + + Create finance__journal_entry_line table + + CREATE TABLE finance__journal_entry_line ( + id uuid PRIMARY KEY, + journal_entry_id uuid NOT NULL REFERENCES finance__journal_entry(id) ON DELETE CASCADE, + line_no integer NOT NULL, + account_code varchar(32) NOT NULL, + debit numeric(18,4) NOT NULL DEFAULT 0, + credit numeric(18,4) NOT NULL DEFAULT 0, + description text, + created_at timestamptz NOT NULL, + created_by varchar(128) NOT NULL, + updated_at timestamptz NOT NULL, + updated_by varchar(128) NOT NULL, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT finance__jel_line_no_uk UNIQUE (journal_entry_id, line_no), + CONSTRAINT finance__jel_nonneg_check CHECK (debit >= 0 AND credit >= 0) + ); + CREATE INDEX finance__jel_entry_idx ON finance__journal_entry_line (journal_entry_id); + CREATE INDEX finance__jel_account_idx ON finance__journal_entry_line (account_code); + + + DROP TABLE finance__journal_entry_line; + + + + diff --git a/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/application/JournalEntryService.kt b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/application/JournalEntryService.kt index 23134a9..1579618 100644 --- a/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/application/JournalEntryService.kt +++ b/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 import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent import org.vibeerp.api.v1.event.orders.SalesOrderShippedEvent import org.vibeerp.pbc.finance.domain.JournalEntry +import org.vibeerp.pbc.finance.domain.JournalEntryLine import org.vibeerp.pbc.finance.domain.JournalEntryStatus import org.vibeerp.pbc.finance.domain.JournalEntryType import org.vibeerp.pbc.finance.infrastructure.JournalEntryJpaRepository +import java.math.BigDecimal import java.util.UUID /** @@ -76,27 +78,21 @@ class JournalEntryService( "[finance] AR ← SalesOrderConfirmedEvent orderCode={} partnerCode={} amount={} {}", event.orderCode, event.partnerCode, event.totalAmount, event.currencyCode, ) - return entries.save( - JournalEntry( - code = code, - type = JournalEntryType.AR, - partnerCode = event.partnerCode, - orderCode = event.orderCode, - amount = event.totalAmount, - currencyCode = event.currencyCode, - postedAt = event.occurredAt, - ), + val entry = JournalEntry( + code = code, + type = JournalEntryType.AR, + partnerCode = event.partnerCode, + orderCode = event.orderCode, + amount = event.totalAmount, + currencyCode = event.currencyCode, + postedAt = event.occurredAt, ) + // Double-entry: DR Accounts Receivable, CR Sales Revenue + addLine(entry, 1, ACCOUNT_AR, event.totalAmount, BigDecimal.ZERO, "AR from sales order ${event.orderCode}") + addLine(entry, 2, ACCOUNT_REVENUE, BigDecimal.ZERO, event.totalAmount, "Revenue from sales order ${event.orderCode}") + return entries.save(entry) } - /** - * React to a `PurchaseOrderConfirmedEvent` by writing an AP row. - * - * Mirror of [recordSalesConfirmed] for the buying side. The - * supplier is now owed the order total. Real cash-flow timing - * (and the actual payable becoming payable on receipt or - * invoice match) lives in the future P5.9 build. - */ fun recordPurchaseConfirmed(event: PurchaseOrderConfirmedEvent): JournalEntry? { val code = event.eventId.value.toString() if (entries.existsByCode(code)) { @@ -110,17 +106,25 @@ class JournalEntryService( "[finance] AP ← PurchaseOrderConfirmedEvent orderCode={} partnerCode={} amount={} {}", event.orderCode, event.partnerCode, event.totalAmount, event.currencyCode, ) - return entries.save( - JournalEntry( - code = code, - type = JournalEntryType.AP, - partnerCode = event.partnerCode, - orderCode = event.orderCode, - amount = event.totalAmount, - currencyCode = event.currencyCode, - postedAt = event.occurredAt, - ), + val entry = JournalEntry( + code = code, + type = JournalEntryType.AP, + partnerCode = event.partnerCode, + orderCode = event.orderCode, + amount = event.totalAmount, + currencyCode = event.currencyCode, + postedAt = event.occurredAt, ) + // Double-entry: DR Inventory, CR Accounts Payable + addLine(entry, 1, ACCOUNT_INVENTORY, event.totalAmount, BigDecimal.ZERO, "Inventory from PO ${event.orderCode}") + addLine(entry, 2, ACCOUNT_AP, BigDecimal.ZERO, event.totalAmount, "AP to supplier ${event.partnerCode}") + return entries.save(entry) + } + + private fun addLine(entry: JournalEntry, lineNo: Int, accountCode: String, debit: BigDecimal, credit: BigDecimal, desc: String) { + val line = JournalEntryLine(lineNo = lineNo, accountCode = accountCode, debit = debit, credit = credit, description = desc) + line.journalEntry = entry + entry.lines.add(line) } /** @@ -238,4 +242,13 @@ class JournalEntryService( @Transactional(readOnly = true) fun findByStatus(status: JournalEntryStatus): List = entries.findByStatus(status) + + companion object { + // Seeded account codes from 003-finance-accounts.xml + const val ACCOUNT_AR: String = "1100" + const val ACCOUNT_INVENTORY: String = "1200" + const val ACCOUNT_AP: String = "2100" + const val ACCOUNT_REVENUE: String = "4100" + const val ACCOUNT_COGS: String = "5100" + } } diff --git a/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/JournalEntry.kt b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/JournalEntry.kt index d4930d5..b15b9aa 100644 --- a/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/JournalEntry.kt +++ b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/JournalEntry.kt @@ -1,9 +1,13 @@ package org.vibeerp.pbc.finance.domain +import jakarta.persistence.CascadeType import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.EnumType import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.OneToMany +import jakarta.persistence.OrderBy import jakarta.persistence.Table import org.hibernate.annotations.JdbcTypeCode import org.hibernate.type.SqlTypes @@ -101,6 +105,15 @@ class JournalEntry( @Column(name = "status", nullable = false, length = 16) var status: JournalEntryStatus = status + @OneToMany( + mappedBy = "journalEntry", + cascade = [CascadeType.ALL], + orphanRemoval = true, + fetch = FetchType.EAGER, + ) + @OrderBy("lineNo ASC") + val lines: MutableList = mutableListOf() + @Column(name = "ext", nullable = false, columnDefinition = "jsonb") @JdbcTypeCode(SqlTypes.JSON) var ext: String = "{}" diff --git a/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/JournalEntryLine.kt b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/JournalEntryLine.kt new file mode 100644 index 0000000..230d4db --- /dev/null +++ b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/JournalEntryLine.kt @@ -0,0 +1,63 @@ +package org.vibeerp.pbc.finance.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity +import java.math.BigDecimal + +/** + * One debit or credit leg of a [JournalEntry]. + * + * The fundamental double-entry invariant: for every journal entry, + * the sum of all lines' [debit] values MUST equal the sum of all + * lines' [credit] values. [JournalEntryService] enforces this + * before saving. + * + * Each line references an [Account] by code (not FK — the account + * lives in the same PBC so a code lookup is fine and avoids JPA + * bidirectional coupling). A line carries a non-negative [debit] + * OR a non-negative [credit]; at most one of the two is non-zero. + * + * **Why a child table instead of inline columns on JournalEntry.** + * A flat "debitAccount + creditAccount" pair on the parent would + * limit every entry to exactly two legs. Real accounting entries + * can have N legs (e.g. a sales invoice debits AR and credits + * both Revenue and Tax Payable). The child table is the + * normalised shape that every GL schema uses. + */ +@Entity +@Table(name = "finance__journal_entry_line") +class JournalEntryLine( + lineNo: Int, + accountCode: String, + debit: BigDecimal = BigDecimal.ZERO, + credit: BigDecimal = BigDecimal.ZERO, + description: String? = null, +) : AuditedJpaEntity() { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "journal_entry_id", nullable = false) + var journalEntry: JournalEntry? = null + + @Column(name = "line_no", nullable = false) + var lineNo: Int = lineNo + + @Column(name = "account_code", nullable = false, length = 32) + var accountCode: String = accountCode + + @Column(name = "debit", nullable = false, precision = 18, scale = 4) + var debit: BigDecimal = debit + + @Column(name = "credit", nullable = false, precision = 18, scale = 4) + var credit: BigDecimal = credit + + @Column(name = "description", nullable = true) + var description: String? = description + + override fun toString(): String = + "JournalEntryLine(id=$id, line=$lineNo, account=$accountCode, dr=$debit, cr=$credit)" +} diff --git a/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/http/JournalEntryController.kt b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/http/JournalEntryController.kt index 7daf10f..278e763 100644 --- a/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/http/JournalEntryController.kt +++ b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/http/JournalEntryController.kt @@ -74,6 +74,15 @@ data class JournalEntryResponse( val amount: BigDecimal, val currencyCode: String, val postedAt: Instant, + val lines: List, +) + +data class JournalEntryLineResponse( + val lineNo: Int, + val accountCode: String, + val debit: BigDecimal, + val credit: BigDecimal, + val description: String?, ) private fun JournalEntry.toResponse(): JournalEntryResponse = @@ -87,4 +96,11 @@ private fun JournalEntry.toResponse(): JournalEntryResponse = amount = amount, currencyCode = currencyCode, postedAt = postedAt, + lines = lines.map { JournalEntryLineResponse( + lineNo = it.lineNo, + accountCode = it.accountCode, + debit = it.debit, + credit = it.credit, + description = it.description, + ) }, ) -- libgit2 0.22.2