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,
+ ) },
)