feat(dds): stronger DDS records, gapless account numbers, locked terms, slab validation
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run

- Optimistic locks (version) on party, agent, batch run and collection entry
- Passbook lines keep branch, entry date, unique ref_no and the journal id they were
  booked under; journal legs carry the voucher; withdrawal legs of savings-backed
  parties are linked; journal ids no longer built from date and amount
- Audit user/date required with FKs on every DDS table; party tied to its product by
  id, with currency, activation and closure user; CHECK and FK constraints, indexes
- DDS account numbers from a locked serial (m_dds_account_number_serial) so rolled-back
  saves leave no gap; existing accounts renumbered from 1 (0624)
- Active DDS accounts: scheme terms and agent cannot be changed; closed accounts cannot
  be edited (validated in the API)
- Apply DDS receipt split by branch and product customer-deposits account
- Amounts limited to paise; batch runs record posted_amount and skipped rows; commission
  shared only over collections counted before the paying run
- Commission slabs: chart must start at 0, have no gap or overlap, percent 0-100, paise;
  correcting a slab moves its neighbour; 10 default Account slabs for a new bank (0625)
- Generating a batch preview replaces the previous un-posted preview of the same job;
  DELETE /dds/batchruns/{id} discards a preview
- Partytransactions API also returns refNo, branch, entry date, journal id, created by

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 17:13:24 +05:30
co-authored by Claude Sonnet 5.5
parent 422ea459be
commit a6dbe01cfc
29 changed files with 1132 additions and 111 deletions
@@ -28,6 +28,7 @@ import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.persistence.Version;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
@@ -61,6 +62,15 @@ import org.apache.fineract.portfolio.client.domain.Client;
@Table(name = "m_dds_agent", uniqueConstraints = { @UniqueConstraint(columnNames = { "agent_code" }, name = "agent_code_UNIQUE") })
public class Agent extends AbstractAuditableCustom {
/**
* Optimistic lock, as a savings account has: two saves of the same row from different requests -
* two collections at once, or a collection during a posting run - cannot both win; the second is
* refused and retried instead of quietly overwriting the first one's balance.
*/
@Version
@Column(name = "version", nullable = false)
private Long version;
@ManyToOne
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@@ -69,6 +69,16 @@ public class CommissionSlab extends AbstractAuditableCustom {
* Corrects this slab, reporting only what actually changed so the audit trail records the
* correction rather than a wholesale rewrite.
*/
/** Moves where this slab starts - used when the slab before it is corrected, so the two still meet. */
public void startAt(final BigDecimal from) {
this.slabFrom = from;
}
/** Moves where this slab ends - used when the slab after it is corrected, so the two still meet. */
public void endAt(final BigDecimal to) {
this.slabTo = to;
}
public Map<String, Object> update(final JsonCommand command) {
final Map<String, Object> actualChanges = new LinkedHashMap<>(4);
@@ -25,6 +25,7 @@ import jakarta.persistence.FetchType;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
@@ -45,6 +46,15 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
@Table(name = "m_dds_batch_run")
public class BatchRun extends AbstractAuditableCustom {
/**
* Optimistic lock, as a savings account has: two saves of the same row from different requests -
* two collections at once, or a collection during a posting run - cannot both win; the second is
* refused and retried instead of quietly overwriting the first one's balance.
*/
@Version
@Column(name = "version", nullable = false)
private Long version;
@Column(name = "job_type", length = 40, nullable = false)
private String jobType;
@@ -87,6 +97,14 @@ public class BatchRun extends AbstractAuditableCustom {
@Column(name = "rows_posted", nullable = false)
private Integer rowsPosted = 0;
/**
* What the run actually moved, beside totalAmount - what it was generated to move. They differ when
* rows were passed over at posting (the money had already been paid by another run), and the run
* says so rather than reading as though the whole total went through.
*/
@Column(name = "posted_amount", scale = 6, precision = 19)
private BigDecimal postedAmount;
/** Why a run stopped, when it did. A background run has nowhere else to say so. */
@Column(name = "failure_reason", length = 500)
private String failureReason;
@@ -155,6 +173,11 @@ public class BatchRun extends AbstractAuditableCustom {
this.rowsPosted = posted;
}
public void recordPosted(final int posted, final BigDecimal amount) {
this.rowsPosted = posted;
this.postedAmount = amount;
}
public void markPosted(final LocalDate postedOnDate) {
this.status = STATUS_POSTED;
this.postedOnDate = postedOnDate;
@@ -23,6 +23,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
@@ -49,6 +50,15 @@ import org.apache.fineract.portfolio.dds.party.domain.Party;
@Table(name = "m_dds_collection_entry")
public class CollectionEntry extends AbstractAuditableCustom {
/**
* Optimistic lock, as a savings account has: two saves of the same row from different requests -
* two collections at once, or a collection during a posting run - cannot both win; the second is
* refused and retried instead of quietly overwriting the first one's balance.
*/
@Version
@Column(name = "version", nullable = false)
private Long version;
/** Waiting for a clerk to count the cash behind it. */
public static final String PENDING = "PENDING";
@@ -178,5 +178,11 @@ public final class PartyData implements Serializable {
private final String nomineeSplitMode;
private final String status;
private final LocalDate closeDate;
/** The product by id, the account currency, and who opened (made operative) and closed it - as a savings account shows. */
private final Long accountTypeId;
private final String currencyCode;
private final LocalDate activatedOnDate;
private final String activatedByUsername;
private final String closedByUsername;
private final Collection<PartyNomineeData> nominees;
}
@@ -28,6 +28,7 @@ import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.persistence.Version;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
@@ -60,6 +61,15 @@ import org.apache.fineract.portfolio.client.domain.Client;
@Table(name = "m_dds_party", uniqueConstraints = { @UniqueConstraint(columnNames = { "party_code" }, name = "party_code_UNIQUE") })
public class Party extends AbstractAuditableCustom {
/**
* Optimistic lock, as a savings account has: two saves of the same row from different requests -
* two collections at once, or a collection during a posting run - cannot both win; the second is
* refused and retried instead of quietly overwriting the first one's balance.
*/
@Version
@Column(name = "version", nullable = false)
private Long version;
/** DDS accounts carry a flat interest rate unless the officer explicitly overrides it at creation. */
private static final BigDecimal DEFAULT_INTEREST_RATE = new BigDecimal("2");
@@ -304,6 +314,29 @@ public class Party extends AbstractAuditableCustom {
@Column(name = "close_date")
private LocalDate closeDate;
/**
* The DDS product the account is on, by id - subType carries its code for the screens, but a code
* can be renamed and an id cannot, so this is what ties the account to its product's terms and
* ledger accounts (as m_savings_account.product_id does).
*/
@Column(name = "account_type_id")
private Long accountTypeId;
/** The account's currency, as a savings account records its own - set from the bank's currency on opening. */
@Column(name = "currency_code", length = 3)
private String currencyCode;
/** When the account became operative, and who made it so - the checker, where the opening was approved. */
@Column(name = "activatedon_date")
private LocalDate activatedOnDate;
@Column(name = "activatedon_userid")
private Long activatedByUserId;
/** Who closed the account, beside closeDate - when it was closed. */
@Column(name = "closedon_userid")
private Long closedByUserId;
@OneToMany(mappedBy = "party", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@OrderBy("id")
private List<PartyNominee> nominees = new ArrayList<>();
@@ -492,10 +525,13 @@ public class Party extends AbstractAuditableCustom {
this.savingsAccountId = savingsAccountId;
}
/** Numbers the account from its id, the same way 0514 numbered the accounts already open. */
public void assignAccountNo() {
if (this.accountNo == null && getId() != null) {
this.accountNo = "DDS" + String.format("%09d", getId());
/**
* Numbers the account from the DDS account serial (m_dds_account_number_serial), not from its id: an
* id is spent by every save that rolls back, and the bank's account numbers must run without gaps.
*/
public void assignAccountNo(final long serial) {
if (this.accountNo == null) {
this.accountNo = "DDS" + String.format("%09d", serial);
}
}
@@ -539,9 +575,22 @@ public class Party extends AbstractAuditableCustom {
* is settled to zero (a full Withdraw Deposite/payout) before it is closed, not closed with money
* still owed to it.
*/
public void close(final LocalDate closeDate) {
public void close(final LocalDate closeDate, final Long closedBy) {
this.status = STATUS_CLOSED;
this.closeDate = closeDate;
this.closedByUserId = closedBy;
}
/** Ties the account to its product by id; called whenever subType is set or changed. */
public void linkProduct(final Long productId) {
this.accountTypeId = productId;
}
/** Stamps the opening: the account's currency, and the day and user that made it operative. */
public void recordActivation(final String currency, final LocalDate on, final Long by) {
this.currencyCode = currency;
this.activatedOnDate = on;
this.activatedByUserId = by;
}
/**
@@ -22,6 +22,7 @@ import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.Builder;
import lombok.Getter;
@@ -51,4 +52,15 @@ public final class PartyTransactionData implements Serializable {
private final BigDecimal balanceAfterTransaction;
/** The DDS voucher this line moved under, e.g. DDS/29092026/0001; empty on lines from before vouchers. */
private final String voucherNo;
/** The branch the line is booked at, the business day it was recorded, and the journal it was booked under. */
private final Long officeId;
private final String officeName;
private final LocalDate submittedOnDate;
private final String journalTransactionId;
/** The line's own unique reference (m_dds_party_transaction.ref_no). */
private final String refNo;
/** Who keyed the line and when, as a savings transaction shows it. */
private final String createdByUsername;
private final LocalDateTime createdDate;
private final String agentName;
}
@@ -23,11 +23,14 @@ import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PostPersist;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
import lombok.Getter;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.organisation.agent.domain.Agent;
import org.apache.fineract.portfolio.dds.party.domain.Party;
import org.apache.fineract.portfolio.dds.voucher.DdsVoucherScope;
@@ -87,6 +90,36 @@ public class PartyTransaction extends AbstractAuditableCustom {
@Column(name = "balance_after_transaction", scale = 6, precision = 19, nullable = false)
private BigDecimal balanceAfterTransaction;
/** The branch the line is booked at - the party's, or its agent's for a line with no party - as savings rows carry. */
@Column(name = "office_id")
private Long officeId;
/**
* The business day the line was recorded, beside transactionDate - the day the money moved. They
* differ when a collection is applied from a cashier's receipt days after it was collected.
*/
@Column(name = "submitted_on_date")
private LocalDate submittedOnDate;
/**
* The acc_gl_journal_entry.transaction_id the line was booked under, so a passbook line and its
* journal can be found from each other. Null for a line that moves nothing on the books (a transfer
* between two customers owed on the same account).
*/
@Column(name = "journal_transaction_id", length = 100)
private String journalTransactionId;
/**
* The line's own reference, unique across DDS, as a savings transaction carries ref_no: what a
* statement, a complaint or a reconciliation quotes to name exactly one line.
*/
@Column(name = "ref_no", length = 40, nullable = false, unique = true)
private String refNo;
/** The voucher the line was printed on - stamped by DdsVoucherService once the operation's voucher is raised. */
@Column(name = "voucher_no", length = 30, insertable = false, updatable = false)
private String voucherNo;
protected PartyTransaction() {
//
}
@@ -108,6 +141,31 @@ public class PartyTransaction extends AbstractAuditableCustom {
this.tokenNo = tokenNo;
this.description = description;
this.balanceAfterTransaction = balanceAfterTransaction;
if (party != null && party.getOffice() != null) {
this.officeId = party.getOffice().getId();
} else if (agent != null && agent.getOffice() != null) {
this.officeId = agent.getOffice().getId();
} else if (agent != null && agent.getClient() != null && agent.getClient().getOffice() != null) {
// An agent saved without a branch is still a client of one.
this.officeId = agent.getClient().getOffice().getId();
}
this.submittedOnDate = DateUtils.getBusinessLocalDate();
}
/** Names the journal this line was booked under, where the caller knows it better than the operation's last one. */
public PartyTransaction linkJournal(final String transactionId) {
this.journalTransactionId = transactionId;
return this;
}
@PrePersist
private void linkToJournal() {
if (this.refNo == null) {
this.refNo = "DDST" + UUID.randomUUID().toString().replace("-", "").substring(0, 20).toUpperCase();
}
if (this.journalTransactionId == null) {
this.journalTransactionId = DdsVoucherScope.lastLedgerTransaction();
}
}
/**
@@ -48,6 +48,8 @@ public final class DdsVoucherScope {
private final Set<String> ledgerTransactionIds = new LinkedHashSet<>();
private final List<Long> partyTransactionIds = new ArrayList<>();
private final List<Line> extraLines = new ArrayList<>();
/** The journal most recently written in this operation - what the next passbook line saved belongs to. */
private String lastLedgerTransactionId;
private DdsVoucherScope() {}
@@ -72,6 +74,24 @@ public final class DdsVoucherScope {
final DdsVoucherScope scope = CURRENT.get();
if (scope != null && transactionId != null) {
scope.ledgerTransactionIds.add(transactionId);
scope.lastLedgerTransactionId = transactionId;
}
}
/**
* The journal the passbook line being saved now belongs to: every DDS movement writes its journal
* first and its passbook line straight after, so it is the last one written in this operation.
*/
public static String lastLedgerTransaction() {
final DdsVoucherScope scope = CURRENT.get();
return scope == null ? null : scope.lastLedgerTransactionId;
}
/** For a movement that writes no journal, so its passbook line does not claim the previous one's. */
public static void forgetLastLedgerTransaction() {
final DdsVoucherScope scope = CURRENT.get();
if (scope != null) {
scope.lastLedgerTransactionId = null;
}
}