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;
}
}
@@ -44,6 +44,8 @@ public final class CommissionSlabCommandFromApiJsonDeserializer {
// A List, not a Set: isOneOfTheseStringValues only lowercases both sides in its List overload.
private static final List<String> SUPPORTED_SLAB_TYPES = List.of("ACCOUNT", "COMMISSION");
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("slabs", "dateFormat", "locale"));
private static final Set<String> SUPPORTED_UPDATE_PARAMETERS = new HashSet<>(
@@ -93,7 +95,7 @@ public final class CommissionSlabCommandFromApiJsonDeserializer {
v.reset().parameterAtIndexArray("slabTo", i).value(extractBigDecimal(slabJson, "slabTo")).notNull()
.zeroOrPositiveAmount();
v.reset().parameterAtIndexArray("commissionPercent", i).value(extractBigDecimal(slabJson, "commissionPercent"))
.notNull().zeroOrPositiveAmount();
.notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
}
}
@@ -134,7 +136,7 @@ public final class CommissionSlabCommandFromApiJsonDeserializer {
if (this.fromApiJsonHelper.parameterExists("commissionPercent", element)) {
v.reset().parameter("commissionPercent")
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("commissionPercent", element)).notNull()
.zeroOrPositiveAmount();
.zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
@@ -21,16 +21,21 @@ package org.apache.fineract.organisation.commissionslab.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import jakarta.persistence.PersistenceException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlab;
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
import org.apache.fineract.organisation.commissionslab.exception.CommissionSlabNotFoundException;
@@ -68,6 +73,14 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
slabs.add(slab);
}
// The chart each type will have once these are added - what is stored plus this request -
// must still read as one unbroken scale from 0.
for (final String type : slabs.stream().map(CommissionSlab::getSlabType).distinct().toList()) {
final List<CommissionSlab> chart = new ArrayList<>(this.commissionSlabRepository.findByType(type));
chart.addAll(slabs.stream().filter(s -> s.getSlabType().equals(type)).toList());
rejectBrokenChart(type, chart);
}
this.commissionSlabRepository.saveAllAndFlush(slabs);
return new CommandProcessingResultBuilder() //
@@ -93,6 +106,9 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
final CommissionSlab slab = this.commissionSlabRepository.findById(slabId)
.orElseThrow(() -> new CommissionSlabNotFoundException(slabId));
final String oldType = slab.getSlabType();
final BigDecimal oldFrom = slab.getSlabFrom();
final BigDecimal oldTo = slab.getSlabTo();
final Map<String, Object> changes = slab.update(command);
// Re-checked after the change, against every other slab of its type. A correction can just as
@@ -101,8 +117,38 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
if (!changes.isEmpty()) {
final List<CommissionSlab> others = this.commissionSlabRepository.findByType(slab.getSlabType()).stream()
.filter(other -> !other.getId().equals(slab.getId())).toList();
// A boundary moved within the same chart takes its neighbour with it, so the two slabs still
// meet a paisa apart and the chart stays unbroken: 10,000 -> 12,000 on this slab's To moves
// the next slab's From from 10,000.01 to 12,000.01.
final List<CommissionSlab> moved = new ArrayList<>();
if (oldType.equals(slab.getSlabType())) {
for (final CommissionSlab other : others) {
if (slab.getSlabTo().compareTo(oldTo) != 0 && other.getSlabFrom().compareTo(oldTo.add(PAISA)) == 0) {
other.startAt(slab.getSlabTo().add(PAISA));
moved.add(other);
}
if (slab.getSlabFrom().compareTo(oldFrom) != 0 && other.getSlabTo().compareTo(oldFrom.subtract(PAISA)) == 0) {
other.endAt(slab.getSlabFrom().subtract(PAISA));
moved.add(other);
}
}
}
rejectIfOverlapping(slab, others);
final List<CommissionSlab> chart = new ArrayList<>(others);
chart.add(slab);
rejectBrokenChart(slab.getSlabType(), chart);
if (!oldType.equals(slab.getSlabType())) {
// Moved to the other type: the chart it left must still be whole too.
rejectBrokenChart(oldType, this.commissionSlabRepository.findByType(oldType).stream()
.filter(other -> !other.getId().equals(slab.getId())).toList());
}
this.commissionSlabRepository.saveAll(moved);
this.commissionSlabRepository.saveAndFlush(slab);
if (!moved.isEmpty()) {
changes.put("neighboursAdjusted", moved.size());
}
}
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(slabId).with(changes).build();
@@ -116,6 +162,63 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
* explain it. Ranges are inclusive at both ends, so touching endpoints count as an overlap: a
* slab ending at 50000 and the next starting at 50000 both match exactly 50000.
*/
/** Collections are rupees and paise, so consecutive slabs meet one paisa apart. */
private static final BigDecimal PAISA = new BigDecimal("0.01");
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
/**
* A chart the commission run can always read: every slab From <= To, a percentage of 0 to 100, money
* to the paisa, starting at 0, and each slab beginning exactly a paisa after the one before ends.
*
* The run matches from <= collection <= to and stops with "no commission slab" for an agent whose
* collection no slab covers - so a gap, or a chart starting above 0, is not a gentler setting, it is
* a run that fails. Refused here, when the chart is saved, rather than discovered on payday.
*/
private void rejectBrokenChart(final String type, final List<CommissionSlab> chart) {
final List<CommissionSlab> sorted = chart.stream().sorted(Comparator.comparing(CommissionSlab::getSlabFrom)).toList();
final List<ApiParameterError> errors = new ArrayList<>();
for (int i = 0; i < sorted.size(); i++) {
final CommissionSlab slab = sorted.get(i);
final String range = plain(slab.getSlabFrom()) + " - " + plain(slab.getSlabTo());
if (slab.getSlabFrom().compareTo(slab.getSlabTo()) > 0) {
errors.add(chartError("from.after.to", type + " slab " + range + ": From is more than To.", "slabFrom"));
}
if (slab.getSlabFrom().stripTrailingZeros().scale() > 2 || slab.getSlabTo().stripTrailingZeros().scale() > 2) {
errors.add(chartError("paise", type + " slab " + range + ": amounts can have at most two decimals (paise).", "slabFrom"));
}
if (slab.getCommissionPercent().signum() < 0 || slab.getCommissionPercent().compareTo(HUNDRED) > 0) {
errors.add(chartError("percent", type + " slab " + range + ": the percentage must be from 0 to 100.",
"commissionPercent"));
}
if (i == 0) {
if (slab.getSlabFrom().signum() != 0) {
errors.add(chartError("not.from.zero", type + " slabs must start at 0 - nothing covers 0 to "
+ plain(slab.getSlabFrom().subtract(PAISA)) + ".", "slabFrom"));
}
} else {
final BigDecimal expected = sorted.get(i - 1).getSlabTo().add(PAISA);
if (slab.getSlabFrom().compareTo(expected) > 0) {
errors.add(chartError("gap", type + " slabs leave a gap: nothing covers " + plain(expected) + " to "
+ plain(slab.getSlabFrom().subtract(PAISA)) + ". Start this slab at " + plain(expected) + ".",
"slabFrom"));
}
}
}
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("error.msg.commissionslab.chart.broken",
"The " + type + " commission chart would not cover every collection: " + errors.get(0).getDefaultUserMessage(),
errors);
}
}
private static ApiParameterError chartError(final String code, final String message, final String parameter) {
return ApiParameterError.parameterError("error.msg.commissionslab." + code, message, parameter);
}
private static String plain(final BigDecimal value) {
return value.setScale(2, RoundingMode.HALF_UP).toPlainString();
}
private void rejectIfOverlapping(final CommissionSlab candidate, final List<CommissionSlab> existing) {
for (final CommissionSlab other : existing) {
if (!other.getSlabType().equals(candidate.getSlabType())) {
@@ -270,24 +270,23 @@ public class DdsLedger {
final String description) {
final GLAccount owed;
final Office office;
Long withdrawalId = null;
Long savingsAccountId = null;
if (party.getSavingsAccountId() != null) {
final SavingsAccount account = load(party.getSavingsAccountId());
final boolean wasBlocked = unlockDebitsIfBlocked(account);
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
this.savingsAccountAssembler.getPivotConfigStatus());
relockDebits(account, wasBlocked);
withdrawalId = withdraw(account, date, amount).getId();
savingsAccountId = account.getId();
owed = controlAccountOf(account);
office = account.office();
} else {
owed = depositLiabilityOf(party);
office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice();
office = officeOf(party);
}
creditCurrentAccount(currentAccountId, date, amount, description);
final String transactionId = "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().substring(0, 8);
final String transactionId = withdrawalId != null ? "S" + withdrawalId : partyLegId(party);
writePair(office, organisationCurrency(), transactionId, date, amount, owed, currentAccountControl(currentAccountId), description,
null, null);
withdrawalId, savingsAccountId);
}
private static final String CURRENT_ACCOUNT_SQL = "select status_enum, account_balance_derived, office_id from m_current_account where id = ?";
@@ -382,13 +381,9 @@ public class DdsLedger {
transfer(from.getSavingsAccountId(), to.getSavingsAccountId(), date, amount, description);
} else if (fromSavings) {
final SavingsAccount account = load(from.getSavingsAccountId());
final boolean wasBlocked = unlockDebitsIfBlocked(account);
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
this.savingsAccountAssembler.getPivotConfigStatus());
relockDebits(account, wasBlocked);
final SavingsAccountTransaction withdrawal = withdraw(account, date, amount);
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account),
depositLiabilityOf(from), description, null, account.getId());
depositLiabilityOf(from), description, withdrawal.getId(), account.getId());
} else if (toSavings) {
final SavingsAccount account = load(to.getSavingsAccountId());
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
@@ -397,7 +392,9 @@ public class DdsLedger {
final GLAccount fromDeposits = depositLiabilityOf(from);
final GLAccount toDeposits = depositLiabilityOf(to);
if (fromDeposits.getId().equals(toDeposits.getId())) {
// Both owed on the same account: nothing to book, but the slip still names who paid whom.
// Both owed on the same account: nothing to book, but the slip still names who paid whom -
// and the two passbook lines claim no journal, since none was written for them.
DdsVoucherScope.forgetLastLedgerTransaction();
voucherSideOnDeposits("DR", from, amount, description);
voucherSideOnDeposits("CR", to, amount, description);
} else {
@@ -410,9 +407,7 @@ public class DdsLedger {
/** One pair for a party with no savings account: at the party's own branch, in the bank's currency. */
private void ddsPair(final Party party, final LocalDate date, final BigDecimal amount, final GLAccount debit, final GLAccount credit,
final String description) {
final Office office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice();
final String transactionId = "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().substring(0, 8);
writePair(office, organisationCurrency(), transactionId, date, amount, debit, credit, description, null, null);
writePair(officeOf(party), organisationCurrency(), partyLegId(party), date, amount, debit, credit, description, null, null);
}
/**
@@ -457,8 +452,12 @@ public class DdsLedger {
if (productCode == null) {
throw new DdsLedgerNotConfiguredException("a DDS product for party " + (party == null ? "?" : party.getPartyCode()));
}
final List<Long> ids = this.jdbcTemplate.queryForList("select " + column + " from m_dds_account_type " // NOSONAR
+ "where account_type_code = ?", Long.class, productCode);
// By the id the account is tied to; by its code only for an account saved before ids were kept.
final List<Long> ids = party.getAccountTypeId() != null
? this.jdbcTemplate.queryForList("select " + column + " from m_dds_account_type where id = ?", Long.class, // NOSONAR
party.getAccountTypeId())
: this.jdbcTemplate.queryForList("select " + column + " from m_dds_account_type where account_type_code = ?", // NOSONAR
Long.class, productCode);
if (ids.isEmpty()) {
throw new DdsLedgerNotConfiguredException("DDS product " + productCode + " (party " + party.getPartyCode() + ")");
}
@@ -484,28 +483,30 @@ public class DdsLedger {
+ "sum(pt.amount) as \"volume\" from m_dds_party_transaction pt join m_dds_party p on p.id = pt.party_id "
+ "left join m_dds_account_type t on t.account_type_code = upper(trim(p.sub_type)) "
+ "where pt.agent_id = ? and pt.transaction_type = 'PARTY_TRAN' and pt.direction = 'CREDIT' "
+ "and pt.commission_batch_run_id is not null %s group by upper(trim(p.sub_type)), t.commission_expense_gl_id "
+ "and pt.commission_batch_run_id is not null and pt.commission_batch_run_id < ? %s group by upper(trim(p.sub_type)), t.commission_expense_gl_id "
+ "order by upper(trim(p.sub_type))";
private static final String UNPAID_COMMISSION_FILTER = "and pt.commission_paid_batch_run_id is null";
private static final String MARK_COMMISSION_PAID_SQL = "update m_dds_party_transaction set commission_paid_batch_run_id = ? "
+ "where agent_id = ? and transaction_type = 'PARTY_TRAN' and direction = 'CREDIT' and commission_batch_run_id is not null "
+ "and commission_paid_batch_run_id is null";
+ "and commission_batch_run_id < ? and commission_paid_batch_run_id is null";
/**
* An agent's commission split across the commission accounts of the products they collected for,
* in proportion to what they collected for each. The last share takes the rounding, so the shares
* always add up to the amount paid.
*
* Collections counted before payments were tracked by run have no payment marked against them; if
* nothing unpaid is left, the agent's counted collections as a whole set the proportions instead.
* Only collections counted by commission runs before the paying run are its basis: a run counted
* after this payment was generated earned commission this payment does not include. Collections
* counted before payments were tracked by run have no payment marked against them; if nothing
* unpaid is left, the agent's counted collections as a whole set the proportions instead.
*/
public Map<GLAccount, BigDecimal> commissionSplit(final Long agentId, final BigDecimal amount) {
public Map<GLAccount, BigDecimal> commissionSplit(final Long agentId, final BigDecimal amount, final Long payingRunId) {
List<Map<String, Object>> basis = this.jdbcTemplate.queryForList(String.format(COMMISSION_BASIS_SQL, UNPAID_COMMISSION_FILTER), // NOSONAR
agentId);
agentId, payingRunId);
if (basis.isEmpty()) {
basis = this.jdbcTemplate.queryForList(String.format(COMMISSION_BASIS_SQL, ""), agentId); // NOSONAR
basis = this.jdbcTemplate.queryForList(String.format(COMMISSION_BASIS_SQL, ""), agentId, payingRunId); // NOSONAR
}
if (basis.isEmpty()) {
throw new DdsLedgerNotConfiguredException("a collection by this agent to tell which DDS product's Agent Commission Expense "
@@ -531,7 +532,7 @@ public class DdsLedger {
for (int i = 0; i < ids.size(); i++) {
final Long id = ids.get(i);
final BigDecimal share = i == ids.size() - 1 || total.signum() == 0 ? amount.subtract(allotted)
: amount.multiply(volumes.get(id)).divide(total, 2, RoundingMode.HALF_EVEN);
: amount.multiply(volumes.get(id)).divide(total, 2, RoundingMode.HALF_UP);
allotted = allotted.add(share);
if (share.signum() != 0) {
shares.put(accounts.get(id), share);
@@ -542,7 +543,7 @@ public class DdsLedger {
/** Marks the collections a commission payment was shared out on, so the next payment starts after them. */
public void markCommissionPaid(final Long agentId, final Long batchRunId) {
this.jdbcTemplate.update(MARK_COMMISSION_PAID_SQL, batchRunId, agentId); // NOSONAR
this.jdbcTemplate.update(MARK_COMMISSION_PAID_SQL, batchRunId, agentId, batchRunId); // NOSONAR
}
/**
@@ -578,15 +579,9 @@ public class DdsLedger {
public void reverseInterest(final Long savingsAccountId, final LocalDate date, final BigDecimal amount, final String description) {
final SavingsAccount account = load(savingsAccountId);
// DDS's own movement, so the debit block comes off for it and goes straight back on.
final boolean wasBlocked = unlockDebitsIfBlocked(account);
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
this.savingsAccountAssembler.getPivotConfigStatus());
relockDebits(account, wasBlocked);
final SavingsAccountTransaction withdrawal = withdraw(account, date, amount);
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account),
productAccount(account.productId(), INTEREST_ON_SAVINGS), description, null, account.getId());
productAccount(account.productId(), INTEREST_ON_SAVINGS), description, withdrawal.getId(), account.getId());
}
/**
@@ -603,15 +598,15 @@ public class DdsLedger {
// The scheme account is blocked for debit so nobody can take the money out at a counter. This
// movement is DDS's own, so the block comes off for it and goes straight back on.
final boolean wasBlocked = unlockDebitsIfBlocked(from);
final SavingsTransactionBooleanValues withdrawalFlags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
this.savingsAccountDomainService.handleWithdrawal(from, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, withdrawalFlags,
this.savingsAccountAssembler.getPivotConfigStatus());
relockDebits(from, wasBlocked);
final SavingsAccountTransaction withdrawal = withdraw(from, date, amount);
final SavingsAccountTransaction deposit = depositWithoutAccounting(to, date, amount);
postPair(to, deposit, date, amount, controlAccountOf(from), controlAccountOf(to), description);
// One leg on each account, each carrying its own account transaction, so both sides of the
// movement can be traced from the journal - not only the deposit.
writeLeg(from.office(), from.getCurrency().getCode(), "S" + withdrawal.getId(), date, amount, controlAccountOf(from),
JournalEntryType.DEBIT, description, withdrawal.getId(), from.getId());
writeLeg(from.office(), from.getCurrency().getCode(), "S" + withdrawal.getId(), date, amount, controlAccountOf(to),
JournalEntryType.CREDIT, description, deposit.getId(), to.getId());
}
/**
@@ -644,15 +639,9 @@ public class DdsLedger {
final GLAccount parkIn) {
final SavingsAccount account = load(savingsAccountId);
// DDS's own movement, so the debit block comes off for it and goes straight back on.
final boolean wasBlocked = unlockDebitsIfBlocked(account);
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
this.savingsAccountAssembler.getPivotConfigStatus());
relockDebits(account, wasBlocked);
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), parkIn,
description, null, account.getId());
final SavingsAccountTransaction withdrawal = withdraw(account, date, amount);
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), parkIn, description,
withdrawal.getId(), account.getId());
}
/**
@@ -761,20 +750,54 @@ public class DdsLedger {
private void postPair(final Office office, final String currencyCode, final LocalDate date, final BigDecimal amount,
final GLAccount debit, final GLAccount credit, final String description, final Long savingsTransactionId,
final Long savingsAccountId) {
final String transactionId = savingsTransactionId == null ? "DDS" + date + amount : "S" + savingsTransactionId;
final String transactionId = savingsTransactionId == null ? "DDS-" + UUID.randomUUID() : "S" + savingsTransactionId;
writePair(office, currencyCode, transactionId, date, amount, debit, credit, description, savingsTransactionId, savingsAccountId);
}
private void writePair(final Office office, final String currencyCode, final String transactionId, final LocalDate date,
final BigDecimal amount, final GLAccount debit, final GLAccount credit, final String description,
final Long savingsTransactionId, final Long savingsAccountId) {
// Noted for the voucher of the operation this leg belongs to - see DdsVoucherService.
writeLeg(office, currencyCode, transactionId, date, amount, debit, JournalEntryType.DEBIT, description, savingsTransactionId,
savingsAccountId);
writeLeg(office, currencyCode, transactionId, date, amount, credit, JournalEntryType.CREDIT, description, savingsTransactionId,
savingsAccountId);
}
private void writeLeg(final Office office, final String currencyCode, final String transactionId, final LocalDate date,
final BigDecimal amount, final GLAccount account, final JournalEntryType type, final String description,
final Long savingsTransactionId, final Long savingsAccountId) {
// Noted for the voucher of the operation this leg belongs to - see DdsVoucherService - and as the
// journal the next passbook line of the operation is linked to (m_dds_party_transaction.journal_transaction_id).
DdsVoucherScope.recordLedgerTransaction(transactionId);
for (final JournalEntryType type : List.of(JournalEntryType.DEBIT, JournalEntryType.CREDIT)) {
this.journalEntryRepository.saveAndFlush(JournalEntry.createNew(office, null,
JournalEntryType.DEBIT.equals(type) ? debit : credit, currencyCode, transactionId, false, date, type, amount,
description, PortfolioProductType.SAVING.getValue(), savingsAccountId, null, null, savingsTransactionId, null, null));
}
this.journalEntryRepository.saveAndFlush(JournalEntry.createNew(office, null, account, currencyCode, transactionId, false, date, type,
amount, description, PortfolioProductType.SAVING.getValue(), savingsAccountId, null, null, savingsTransactionId, null,
null));
}
/**
* A journal id for a leg on DDS's own books - the party's code to find it by and a full UUID to keep it
* unique: DDS-PT0003-1b4e... Never built from the date and amount, which two parties can share.
*/
private static String partyLegId(final Party party) {
return "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
/** The branch a party's legs are booked at: the party's own, or its agent's for a party opened without one. */
public Office officeOf(final Party party) {
return party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice();
}
/**
* Takes money off a scheme account that DDS keeps blocked for debits, as DDS's own movement - the block
* comes off for it and goes straight back on - and returns the withdrawal so its journal can name it.
*/
private SavingsAccountTransaction withdraw(final SavingsAccount account, final LocalDate date, final BigDecimal amount) {
final boolean wasBlocked = unlockDebitsIfBlocked(account);
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(account,
DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags, this.savingsAccountAssembler.getPivotConfigStatus());
relockDebits(account, wasBlocked);
return withdrawal;
}
private GLAccount controlAccountOf(final SavingsAccount account) {
@@ -336,6 +336,9 @@ public class DdsVoucherService {
LAST_ISSUED.set(voucherNo);
stamp("update m_dds_party_transaction set voucher_no = ? where id in (%s)", voucherNo, scope.getPartyTransactionIds());
// The journal legs carry the voucher too, in ref_num, so the slip, the passbook lines and the
// journal can each be found from the others - as a savings voucher's legs can.
stampLegs(voucherNo, scope.getLedgerTransactionIds());
stamp("update m_savings_account_transaction set voucher_no = ? where voucher_no is null and id in (%s)", voucherNo,
savingsTransactions);
}
@@ -427,6 +430,17 @@ public class DdsVoucherService {
"select deposit_liability_gl_id from m_dds_account_type where deposit_liability_gl_id is not null", Long.class));
}
private void stampLegs(final String voucherNo, final Collection<String> transactionIds) {
if (transactionIds.isEmpty()) {
return;
}
final List<Object> args = new ArrayList<>();
args.add(voucherNo);
args.addAll(transactionIds);
this.jdbcTemplate.update("update acc_gl_journal_entry set ref_num = ? where ref_num is null and transaction_id in (" // NOSONAR
+ placeholders(transactionIds) + ")", args.toArray());
}
private void stamp(final String sql, final String voucherNo, final Collection<Long> ids) {
if (ids.isEmpty()) {
return;
@@ -115,6 +115,13 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
changesOnly.put("accountsRepriced", openAccounts.size());
}
// A renamed product: the accounts on it - tied to it by id - show the new code.
if (changesOnly.containsKey("accountTypeCode")) {
final int moved = this.jdbcTemplate.update("update m_dds_party set sub_type = ? where account_type_id = ?", // NOSONAR
accountType.getAccountTypeCode(), accountTypeId);
changesOnly.put("accountsRenamed", moved);
}
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(accountTypeId) //
@@ -588,7 +588,7 @@ public class DdsApprovalReadService {
for (final Map<String, Object> runRow : this.jdbcTemplate.queryForList( // NOSONAR
"select agent_id, amount from m_dds_batch_run_row where batch_run_id = ? and amount > 0 order by id", resourceId)) {
try {
this.ddsLedger.commissionSplit(((Number) runRow.get("agent_id")).longValue(), (BigDecimal) runRow.get("amount"))
this.ddsLedger.commissionSplit(((Number) runRow.get("agent_id")).longValue(), (BigDecimal) runRow.get("amount"), resourceId)
.forEach((account, share) -> {
byAccount.merge(account.getGlCode(), share, BigDecimal::add);
names.put(account.getGlCode(), account.getName());
@@ -22,6 +22,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
@@ -38,6 +39,8 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.dds.batch.data.BatchRunData;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunReadPlatformService;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.springframework.stereotype.Component;
@Path("/v1/dds/batchruns")
@@ -52,6 +55,20 @@ public class BatchRunsApiResource {
private final BatchRunReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DdsVoucherService ddsVoucherService;
private final BatchRunWritePlatformService writePlatformService;
@DELETE
@Path("{batchRunId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Discard a previewed DDS Batch Run", description = "Deletes a run that was generated but never posted - "
+ "the preview the screen's Discard button throws away. Refused for a run that is posted, posting, failed, or whose "
+ "posting is waiting for approval.")
public CommandProcessingResult discard(@PathParam("batchRunId") @Parameter(description = "batchRunId") final Long batchRunId) {
// Whoever may generate a preview may throw one away; it moved nothing.
this.context.authenticatedUser().validateHasPermissionTo("GENERATE_" + RESOURCE_NAME_FOR_PERMISSIONS);
this.writePlatformService.discardBatchRun(batchRunId);
return new CommandProcessingResultBuilder().withEntityId(batchRunId).build();
}
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@@ -30,6 +30,12 @@ public interface BatchRunWritePlatformService {
CommandProcessingResult postBatchRun(Long batchRunId, JsonCommand command);
/**
* Throws away a run that was only previewed - generated and never posted, nor waiting for a
* checker to approve its posting. Nothing was applied by generating it, so nothing is undone.
*/
void discardBatchRun(Long batchRunId);
/**
* Posts interest for every scheme whose Account Type says to do it automatically and whose posting
* day falls on the given date. Driven by the Post DDS Interest scheduled job.
@@ -29,6 +29,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
@@ -84,6 +85,7 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
* re-running it for the same period finds no days left; a posting takes its amount off the parked
* figure, so re-posting finds nothing left to move.
*/
@Slf4j
@RequiredArgsConstructor
public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWritePlatformService {
@@ -194,6 +196,9 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType);
}
// A new preview of the same job replaces the one before it: generating twice used to leave two
// identical "not posted" runs on the list, and nobody could tell which one to post.
discardSupersededPreviews(jobType, subType);
this.batchRunRepository.saveAndFlush(batchRun);
return new CommandProcessingResultBuilder() //
@@ -202,6 +207,35 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
.build();
}
/**
* A run that is only a preview: calculated, never posted, not posting or failed, and with no
* posting of it waiting for a checker (a held posting names the run, and approving it later must
* find it). Only such a run is ever deleted - generating applies nothing, so nothing is lost.
*/
private static final String PREVIEW_ONLY = "r.status = 'CALCULATED' and not exists (select 1 from m_portfolio_command_source c "
+ "where c.entity_name = 'DDSBATCH' and c.resource_id = r.id and c.status = 2)";
private void discardSupersededPreviews(final String jobType, final String subType) {
final String sameJob = "r.job_type = ? and coalesce(r.sub_type, '') = coalesce(?, '') and " + PREVIEW_ONLY;
this.jdbcTemplate.update("delete from m_dds_batch_run_row where batch_run_id in (select r.id from m_dds_batch_run r where " // NOSONAR
+ sameJob + ")", jobType, subType);
this.jdbcTemplate.update("delete from m_dds_batch_run r where " + sameJob, jobType, subType); // NOSONAR
}
@Transactional
@Override
public void discardBatchRun(final Long batchRunId) {
final List<Long> found = this.jdbcTemplate.queryForList("select r.id from m_dds_batch_run r where r.id = ? and " + PREVIEW_ONLY, // NOSONAR
Long.class, batchRunId);
if (found.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.dds.batch.run.not.discardable",
"Batch run #" + batchRunId + " cannot be discarded: only a run that was generated and not posted (or sent for"
+ " posting approval) can be.");
}
this.jdbcTemplate.update("delete from m_dds_batch_run_row where batch_run_id = ?", batchRunId); // NOSONAR
this.jdbcTemplate.update("delete from m_dds_batch_run where id = ?", batchRunId); // NOSONAR
}
/**
* Parses the "Branch" checklist selection - null/empty means no filter ("All"). Matched against
* Agent.homeBranch by name, since that field is plain text (not a real Office FK) mirroring the
@@ -601,26 +635,47 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
final String jobType = batchRun.getJobType();
final LocalDate jobDate = batchRun.getJobDate();
// Only rows that actually moved money are counted, and their sum kept beside the run's total: a
// posting row is passed over when another run has already paid what it lists, and the run must
// not read as though it paid it again.
int done = 0;
BigDecimal moved = BigDecimal.ZERO;
for (final BatchRunRow row : batchRun.getRows()) {
switch (jobType) {
case INTEREST_CALCULATION -> accrue(row, jobDate, false);
case OD_INTEREST_CALCULATION -> accrue(row, jobDate, true);
final boolean applied = switch (jobType) {
case INTEREST_CALCULATION -> {
accrue(row, jobDate, false);
yield true;
}
case OD_INTEREST_CALCULATION -> {
accrue(row, jobDate, true);
yield true;
}
case INTEREST_POSTING -> postInterest(row, batchRun, false);
case OD_INTEREST_POSTING -> postInterest(row, batchRun, true);
case COMMISSION_CALCULATION -> accrueCommission(row, batchRun);
case COMMISSION_CALCULATION -> {
accrueCommission(row, batchRun);
yield true;
}
case COMMISSION_POSTING -> payCommission(row, batchRun);
case MATURITY_AUTO_CLOSE -> closeAtMaturity(row);
case MATURITY_AUTO_CLOSE -> {
closeAtMaturity(row);
yield true;
}
default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType);
};
if (applied) {
done++;
moved = moved.add(row.getAmount() == null ? BigDecimal.ZERO : row.getAmount());
} else {
log.warn("DDS batch run #{}: row {} passed over - what it lists was already paid", batchRun.getId(), row.getId());
}
done++;
}
// Only at the end. A crediting run is one transaction from start to finish, so a count written
// part-way through would be invisible outside it until the run commits - and writing it from a
// transaction of its own would have two of them contending for the same row. The status says
// POSTING while it works, which is what a clerk actually needs to know.
batchRun.recordProgress(done);
batchRun.recordPosted(done, moved);
}
/**
@@ -667,10 +722,10 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
* same accrued interest would each list it in full, and paying both would credit interest that
* was never earned.
*/
private void postInterest(final BatchRunRow row, final BatchRun batchRun, final boolean odInterest) {
private boolean postInterest(final BatchRunRow row, final BatchRun batchRun, final boolean odInterest) {
final Party party = partyOf(row.getPartyId());
if (party.accruedInterest(odInterest).compareTo(row.getAmount()) < 0) {
return;
return false;
}
final BigDecimal newBalance = party.postInterest(row.getAmount(), odInterest);
@@ -683,6 +738,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
final String transactionType = odInterest ? "OD_INTEREST_POST" : "INTEREST_POST";
this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), batchRun.getJobDate(), transactionType,
DIRECTION_CREDIT, null, null, null, row.getAmount(), null, null, "DDS batch run #" + batchRun.getId(), newBalance));
return true;
}
/**
@@ -721,12 +777,12 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
* customers and not yet handed to a branch, which commission has nothing to do with. The entry is
* recorded against the balance as it stands, so the ledger reads consistently.
*/
private void payCommission(final BatchRunRow row, final BatchRun batchRun) {
private boolean payCommission(final BatchRunRow row, final BatchRun batchRun) {
final Agent agent = agentOf(row.getAgentId());
// Only what is actually owed can be paid: two posting runs generated over the same owed
// figure would each list it in full, and paying both would pay commission twice.
if (agent.getCommission().compareTo(row.getAmount()) < 0) {
return;
return false;
}
agent.payOutCommission(row.getAmount());
@@ -735,6 +791,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
this.partyTransactionRepository.save(new PartyTransaction(null, agent, batchRun.getJobDate(), "COMMISSION_POST", DIRECTION_CREDIT,
null, null, agent.getAgentSavingAccountNo(), row.getAmount(), null, null, "DDS batch run #" + batchRun.getId(),
agent.getAgentCurrentBalance()));
return true;
}
/**
@@ -794,7 +851,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
// Each product the agent collected for books its share on its own commission account; the
// collections the share was worked out on are then marked as paid for.
final Map<GLAccount, BigDecimal> shares = this.ddsLedger.commissionSplit(agent.getId(), amount);
final Map<GLAccount, BigDecimal> shares = this.ddsLedger.commissionSplit(agent.getId(), amount, batchRun.getId());
this.ddsLedger.payCommission(((Number) account.get("id")).longValue(), batchRun.getJobDate(), amount,
"DDS commission, batch run #" + batchRun.getId(), shares);
this.ddsLedger.markCommissionPaid(agent.getId(), batchRun.getId());
@@ -115,6 +115,7 @@ public final class CollectionEntryCommandFromApiJsonDeserializer {
.inMinMaxRange(1, DdsSchedule.CATCH_UP_DAYS);
} else {
v.reset().parameter(ENTRIES).parameterAtIndexArray("amount", i).value(decimalOf(row)).notNull().positiveAmount();
toThePaisa(v, decimalOf(row));
}
final String mode = stringOf(row, "transactionMode");
@@ -201,4 +202,14 @@ public final class CollectionEntryCommandFromApiJsonDeserializer {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
}
}
/**
* Money is kept to the paisa, as a savings account keeps it: 100.555 is refused rather than stored
* on the passbook and the journal as typed.
*/
private static void toThePaisa(final DataValidatorBuilder v, final BigDecimal amount) {
if (amount != null && amount.stripTrailingZeros().scale() > 2) {
v.failWithCodeAndMessage("more.than.two.decimals", "An amount can have at most two decimal places (paise).", amount);
}
}
}
@@ -49,6 +49,7 @@ import org.apache.fineract.infrastructure.security.service.PlatformSecurityConte
import org.apache.fineract.organisation.agent.domain.Agent;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntry;
import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntryRepository;
import org.apache.fineract.portfolio.dds.collection.exception.CollectionEntryNotFoundException;
@@ -336,39 +337,49 @@ public class CollectionEntryWritePlatformServiceJpaRepositoryImpl implements Col
}
final String description = "DDS collection by " + agent.getAgentCode() + ", voucher " + String.join(", ", voucherNos);
// What the customers on DDS's own books are now owed, by the customer-deposits account of
// their product - two products may owe on two accounts.
final Map<Long, BigDecimal> pooled = new LinkedHashMap<>();
final Map<Long, GLAccount> depositAccounts = new LinkedHashMap<>();
// What the customers on DDS's own books are now owed, by the customer-deposits account of their
// product and the branch they bank at - two products owe on two accounts, and a branch's DDS
// Customer Deposits must add up to its own customers' balances.
final String journalId = "DDSR-A" + applicationId;
final Map<String, BigDecimal> pooled = new LinkedHashMap<>();
final Map<String, GLAccount> depositAccounts = new LinkedHashMap<>();
final Map<String, Office> offices = new LinkedHashMap<>();
for (final CollectionEntry entry : pending) {
final Party party = collectableParty(entry.getParty().getPartyCode(), agent);
if (party.getSavingsAccountId() != null) {
final boolean pooledEntry = party.getSavingsAccountId() == null;
if (!pooledEntry) {
this.ddsLedger.applyAgentReceiptToSavings(party, settledOn, entry.getAmount(), receiptAccount, description);
} else {
final GLAccount deposits = this.ddsLedger.depositLiabilityOf(party);
depositAccounts.putIfAbsent(deposits.getId(), deposits);
pooled.merge(deposits.getId(), entry.getAmount(), BigDecimal::add);
final Office office = this.ddsLedger.officeOf(party);
final String key = office.getId() + ":" + deposits.getId();
depositAccounts.putIfAbsent(key, deposits);
offices.putIfAbsent(key, office);
pooled.merge(key, entry.getAmount(), BigDecimal::add);
}
// The customer's own statement records the day the money was collected, as it always has.
// The agent's cash in hand is not raised: this cash was counted in at the counter, not
// carried.
final BigDecimal balanceAfter = party.credit(entry.getAmount());
final PartyTransaction transaction = this.partyTransactionRepository.saveAndFlush(new PartyTransaction(party, agent,
entry.getEntryDate(), "PARTY_TRAN", "CREDIT", entry.getTransactionMode(), "GL", receiptAccount.getGlCode(),
entry.getAmount(), entry.getChequeNo(), entry.getTokenNo(), description, balanceAfter));
final PartyTransaction line = new PartyTransaction(party, agent, entry.getEntryDate(), "PARTY_TRAN", "CREDIT",
entry.getTransactionMode(), "GL", receiptAccount.getGlCode(), entry.getAmount(), entry.getChequeNo(),
entry.getTokenNo(), description, balanceAfter);
// A pooled line's journal is written after the loop, so it is named here rather than taken
// from whatever the operation wrote last.
final PartyTransaction transaction = this.partyTransactionRepository
.saveAndFlush(pooledEntry ? line.linkJournal(journalId) : line);
entry.markSettledByReceipt(settledOn, transaction.getId(), applicationId);
this.collectionEntryRepository.save(entry);
}
// Everyone on DDS's own accounts, in one pair per customer-deposits account, all under one journal id.
final String journalId = "DDSR-A" + applicationId;
// Everyone on DDS's own accounts, in one pair per branch and customer-deposits account, all under one journal id.
boolean journalled = false;
for (final Map.Entry<Long, BigDecimal> owed : pooled.entrySet()) {
for (final Map.Entry<String, BigDecimal> owed : pooled.entrySet()) {
if (owed.getValue().signum() > 0) {
this.ddsLedger.applyAgentReceipts(agent.getOffice(), settledOn, owed.getValue(), receiptAccount,
this.ddsLedger.applyAgentReceipts(offices.get(owed.getKey()), settledOn, owed.getValue(), receiptAccount,
depositAccounts.get(owed.getKey()), journalId, description);
journalled = true;
}
@@ -104,7 +104,10 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
+ " p.freeze_code as freezeCode, p.adv_code as advCode, p.credit_to_branch as creditToBranch, "
+ " p.credit_to_account_type as creditToAccountType, p.credit_to_account as creditToAccount, "
+ " p.nominee_applicable as nomineeApplicable, p.nominee_split_mode as nomineeSplitMode, "
+ " p.status as status, p.close_date as closeDate "
+ " p.status as status, p.close_date as closeDate, p.account_type_id as accountTypeId, "
+ " p.currency_code as currencyCode, p.activatedon_date as activatedOnDate, "
+ " (select u.username from m_appuser u where u.id = p.activatedon_userid) as activatedByUsername, "
+ " (select u.username from m_appuser u where u.id = p.closedon_userid) as closedByUsername "
// Left join: sub_type is a plain code column rather than a foreign key, so a party
// whose account type was renamed away or predates the master still lists, just
// without a description.
@@ -214,6 +217,11 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
.nomineeSplitMode(rs.getString("nomineeSplitMode")) //
.status(rs.getString("status")) //
.closeDate(JdbcSupport.getLocalDate(rs, "closeDate")) //
.accountTypeId(JdbcSupport.getLong(rs, "accountTypeId")) //
.currencyCode(rs.getString("currencyCode")) //
.activatedOnDate(JdbcSupport.getLocalDate(rs, "activatedOnDate")) //
.activatedByUsername(rs.getString("activatedByUsername")) //
.closedByUsername(rs.getString("closedByUsername")) //
.nominees(new ArrayList<>()) //
.build();
}
@@ -345,10 +353,10 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
@Override
public String lastAccountNo() {
// The newest account by id - an account's number is given from its id, so the highest id is the
// one opened last. Closed accounts count: this says what was opened, not what is still open.
// The highest number on the DDS account serial - the account opened last. Closed accounts count:
// this says what was opened, not what is still open.
final List<String> found = this.jdbcTemplate.queryForList( // NOSONAR
"select account_no from m_dds_party where account_no is not null order by id desc limit 1", String.class);
"select account_no from m_dds_party where account_no is not null order by account_no desc limit 1", String.class);
return found.isEmpty() ? null : found.get(0);
}
@@ -29,6 +29,7 @@ import java.time.LocalDate;
import java.time.Period;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -82,6 +83,7 @@ import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepo
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.orm.jpa.JpaSystemException;
@@ -133,15 +135,20 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
final String partyCode = generateNextPartyCode();
final Party party = Party.fromJson(client, agent, officeNamed(command.longValueOfParameterNamed(OFFICE_ID)), partyCode,
command);
party.takeSchemeRate(schemeOf(party).getInterestRate());
final DdsAccountType scheme = schemeOf(party);
party.takeSchemeRate(scheme.getInterestRate());
party.linkProduct(scheme.getId());
// Operative from the day it is opened - by whoever executes this, the checker where the
// opening went through maker-checker - in the bank's currency.
party.recordActivation(organisationCurrency(), DateUtils.getBusinessLocalDate(), currentUserId());
linkSavingsAccountIfProvided(party, clientId, command);
addNomineesFromCommand(party, command);
// The party is the DDS account: nothing is opened in the savings tables for it. Its
// number comes from its id, so it is given once the first save has made one.
this.partyRepository.saveAndFlush(party);
party.assignAccountNo();
// The party is the DDS account: nothing is opened in the savings tables for it. Its number
// is the next on the DDS account serial, taken in this transaction - so an opening that
// fails or is rolled back hands its number back, and the numbers run without gaps.
party.assignAccountNo(nextAccountSerial());
this.partyRepository.saveAndFlush(party);
return new CommandProcessingResultBuilder() //
@@ -166,6 +173,10 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
this.fromApiJsonDeserializer.validateForUpdate(command.json());
final Party partyForUpdate = this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId));
refuseEditOfClosedAccount(partyForUpdate);
// Asked before the update changes anything: is the branch this request names a different one?
final boolean officeMoves = command.parameterExists(OFFICE_ID) && (partyForUpdate.getOffice() == null
|| !partyForUpdate.getOffice().getId().equals(command.longValueOfParameterNamed(OFFICE_ID)));
// Checked against whoever owns the party once this update lands, not who owns it now -
// moving the party to another customer and naming the old one's account in the same save
@@ -175,13 +186,16 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
validateCreditToAccount(owningClientId, command);
final Map<String, Object> changesOnly = partyForUpdate.update(command);
refuseChangeOfLockedTerms(partyForUpdate, changesOnly, officeMoves);
// Only when it actually changed: a party already sitting on a since-retired account type
// must stay editable for everything else.
if (changesOnly.containsKey("subType")) {
validateSubType((String) changesOnly.get("subType"));
// Moved to another scheme: it takes that scheme's rate.
partyForUpdate.takeSchemeRate(schemeOf(partyForUpdate).getInterestRate());
// Moved to another scheme: it takes that scheme's rate, and is tied to it by id.
final DdsAccountType newScheme = schemeOf(partyForUpdate);
partyForUpdate.takeSchemeRate(newScheme.getInterestRate());
partyForUpdate.linkProduct(newScheme.getId());
changesOnly.put("interestRate", partyForUpdate.getInterestRate());
}
@@ -244,7 +258,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
}
final LocalDate closeDate = command.localDateValueOfParameterNamed("closeDate");
party.close(closeDate);
party.close(closeDate, currentUserId());
this.partyRepository.saveAndFlush(party);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId).build();
@@ -346,7 +360,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
// open only leaves a second step to forget - an account that reads as active in every report and
// that an agent could still collect on. The same as Maturity Auto Close does, on the day the
// money moved.
party.close(DateUtils.getBusinessLocalDate());
party.close(DateUtils.getBusinessLocalDate(), currentUserId());
this.partyRepository.saveAndFlush(party);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId).build();
@@ -444,7 +458,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
// The balance is nil now, which is the one thing closing insists on. Closed on the business
// date rather than the maturity date: this is the day the money actually moved, and a close
// date earlier than its own payout would read as though the account had been shut first.
party.close(DateUtils.getBusinessLocalDate());
party.close(DateUtils.getBusinessLocalDate(), currentUserId());
this.partyRepository.saveAndFlush(party);
return new CommandProcessingResultBuilder().withEntityId(partyId).build();
@@ -740,6 +754,68 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
* counted at another. Refuses an id that names no branch rather than leaving the account
* unplaced.
*/
/**
* Who is doing this, for the account's own record of it. A background run has no logged-in user;
* it is recorded as the system administrator, as the audit columns record it (AuditorAwareImpl).
*/
private Long currentUserId() {
final AppUser user = this.context.getAuthenticatedUserIfPresent();
return user != null ? user.getId() : SYSTEM_ADMIN_USER_ID;
}
private static final Long SYSTEM_ADMIN_USER_ID = 1L;
/** The bank's currency - a DDS account is opened in it. */
private String organisationCurrency() {
final List<String> codes = this.jdbcTemplate.queryForList("select code from m_organisation_currency order by id limit 1", // NOSONAR
String.class);
return codes.isEmpty() ? null : codes.get(0);
}
/**
* What an active DDS account's scheme is built on, and so cannot change while it runs - as a savings
* account's product, currency and opening cannot: the customer and product, the branch whose books
* carry it, the opening and its balance, and the deposit terms (daily amount, deposit, days, months)
* that its maturity amount and interest are worked out from, with the dates and amounts that follow
* from them. Changing one mid-scheme would leave the balance, the interest already posted and the
* maturity figure answering to terms the account no longer has. The collecting agent is locked too:
* the agent's pending collections, commission and cash in hand are counted against the account.
* Contact details, nominees, lien and freeze, SMS/e-mail and where the maturity is paid stay editable.
*/
private static final List<String> LOCKED_WHEN_ACTIVE = List.of("clientId", "subType", "agentId", "openingDate", "openingBalance",
"dailyDepositAmount", "depositAmount", "days", "month", "interestDate", "payDate", "paymentAmount", "reopenDate",
"lastAccount");
private void refuseEditOfClosedAccount(final Party party) {
if (party.isClosed()) {
final String message = "DDS account " + party.getAccountNo() + " is closed and cannot be edited.";
throw new PlatformApiDataValidationException("error.msg.dds.party.closed.not.editable", message,
List.of(ApiParameterError.parameterError("error.msg.dds.party.closed.not.editable", message, "status")));
}
}
private void refuseChangeOfLockedTerms(final Party party, final Map<String, Object> changes, final boolean officeMoves) {
if (!Party.STATUS_ACTIVE.equals(party.getStatus())) {
return;
}
final List<ApiParameterError> errors = new ArrayList<>();
for (final String field : LOCKED_WHEN_ACTIVE) {
if (changes.containsKey(field)) {
errors.add(ApiParameterError.parameterError("error.msg.dds.party.term.locked." + field,
"'" + field + "' cannot be changed on an active DDS account.", field));
}
}
if (officeMoves) {
errors.add(ApiParameterError.parameterError("error.msg.dds.party.term.locked." + OFFICE_ID,
"The branch cannot be changed on an active DDS account.", OFFICE_ID));
}
if (!errors.isEmpty()) {
// Thrown inside the update's transaction, so nothing the request changed is kept.
throw new PlatformApiDataValidationException("error.msg.dds.party.terms.locked",
"These cannot be changed on an active DDS account: " + errors.size() + " field(s).", errors);
}
}
private Office officeNamed(final Long officeId) {
return this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
}
@@ -874,7 +950,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
payOutRemainder(party, command, closeDate, lockinOverrideReason);
party.close(closeDate);
party.close(closeDate, currentUserId());
this.partyRepository.saveAndFlush(party);
final Map<String, Object> changes = new HashMap<>();
@@ -1060,6 +1136,20 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
}
}
/**
* The next DDS account number. The update locks the serial's one row until this transaction ends,
* so two openings at once queue on it rather than taking the same number, and a rollback undoes the
* increment - unlike a sequence, which never gives a number back.
*/
private long nextAccountSerial() {
final Long next = this.jdbcTemplate.queryForObject( // NOSONAR
"update m_dds_account_number_serial set last_value = last_value + 1 where id = 1 returning last_value", Long.class);
if (next == null) {
throw new IllegalStateException("m_dds_account_number_serial has no row - migration 0624 has not run");
}
return next;
}
private String generateNextPartyCode() {
final Long maxSuffix = this.partyRepository.retrieveMaxPartyCodeSuffix();
final long nextSuffix = (maxSuffix == null ? 0L : maxSuffix) + 1L;
@@ -22,6 +22,7 @@ import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
@@ -161,6 +162,7 @@ public final class PartyTransactionCommandFromApiJsonDeserializer {
} else {
v.reset().parameter(COLLECTIONS).parameterAtIndexArray("amount", i).value(extractBigDecimal(row, "amount"))
.notNull().positiveAmount();
toThePaisa(v, extractBigDecimal(row, "amount"));
}
}
}
@@ -171,9 +173,11 @@ public final class PartyTransactionCommandFromApiJsonDeserializer {
.notBlank();
v.reset().parameter("withdrawAmount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("withdrawAmount", element))
.notNull().positiveAmount();
toThePaisa(v, this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("withdrawAmount", element));
} else if ("AGENT_TRAN".equals(transactionType)) {
v.reset().parameter("amount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("amount", element)).notNull()
.positiveAmount();
toThePaisa(v, this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("amount", element));
// An agent handing its round's takings to the branch is a counter transaction like any
// other, so it says how the money arrived on the same terms a collection does.
@@ -207,4 +211,14 @@ public final class PartyTransactionCommandFromApiJsonDeserializer {
dataValidationErrors);
}
}
/**
* Money is kept to the paisa, as a savings account keeps it: 100.555 is refused rather than stored
* on the passbook and the journal as typed.
*/
private static void toThePaisa(final DataValidatorBuilder v, final BigDecimal amount) {
if (amount != null && amount.stripTrailingZeros().scale() > 2) {
v.failWithCodeAndMessage("more.than.two.decimals", "An amount can have at most two decimal places (paise).", amount);
}
}
}
@@ -45,9 +45,13 @@ public class PartyTransactionReadPlatformServiceImpl implements PartyTransaction
+ " t.agent_id as agentId, a.agent_code as agentCode, t.transaction_date as transactionDate, "
+ " t.transaction_type as transactionType, t.direction as direction, t.transaction_mode as transactionMode, "
+ " t.account_type as accountType, t.account_no as accountNo, t.amount as amount, t.cheque_no as chequeNo, "
+ " t.token_no as tokenNo, t.description as description, t.balance_after_transaction as balanceAfterTransaction, t.voucher_no as voucherNo "
+ " t.token_no as tokenNo, t.description as description, t.balance_after_transaction as balanceAfterTransaction, t.voucher_no as voucherNo, "
+ " t.office_id as officeId, o.name as officeName, t.submitted_on_date as submittedOnDate, "
+ " t.journal_transaction_id as journalTransactionId, t.ref_no as refNo, "
+ " u.username as createdByUsername, t.created_date as createdDate, a.agent_name as agentName "
+ " from m_dds_party_transaction t left join m_dds_party p on p.id = t.party_id "
+ " join m_dds_agent a on a.id = t.agent_id";
+ " join m_dds_agent a on a.id = t.agent_id left join m_office o on o.id = t.office_id "
+ " left join m_appuser u on u.id = t.createdby_id";
}
@Override
@@ -71,6 +75,14 @@ public class PartyTransactionReadPlatformServiceImpl implements PartyTransaction
.description(rs.getString("description")) //
.balanceAfterTransaction(rs.getBigDecimal("balanceAfterTransaction")) //
.voucherNo(rs.getString("voucherNo")) //
.officeId(JdbcSupport.getLong(rs, "officeId")) //
.officeName(rs.getString("officeName")) //
.submittedOnDate(JdbcSupport.getLocalDate(rs, "submittedOnDate")) //
.journalTransactionId(rs.getString("journalTransactionId")) //
.refNo(rs.getString("refNo")) //
.createdByUsername(rs.getString("createdByUsername")) //
.createdDate(rs.getTimestamp("createdDate") == null ? null : rs.getTimestamp("createdDate").toLocalDateTime()) //
.agentName(rs.getString("agentName")) //
.build();
}
}
@@ -193,4 +193,8 @@
<include file="parts/0619_dds_gl_six_digit_codes_and_agent_gl.xml" relativeToChangelogFile="true"/>
<include file="parts/0620_dds_product_gl_mapping.xml" relativeToChangelogFile="true"/>
<include file="parts/0621_dds_product_drop_unused_gl.xml" relativeToChangelogFile="true"/>
<include file="parts/0622_dds_transaction_integrity.xml" relativeToChangelogFile="true"/>
<include file="parts/0623_dds_account_audit_and_links.xml" relativeToChangelogFile="true"/>
<include file="parts/0624_dds_account_number_serial.xml" relativeToChangelogFile="true"/>
<include file="parts/0625_dds_default_commission_slabs.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,148 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Brings DDS records up to what a savings account keeps.
Optimistic locks. A party balance, an agent figures, a batch run status and a collection entry
state are read, changed and written back; with two requests at once the second write used to
overwrite the first. A version column makes the second one fail and be retried instead.
-->
<changeSet id="0622_dds_party_version" author="CBS">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_dds_party" columnName="version"/></not></preConditions>
<addColumn tableName="m_dds_party">
<column name="version" type="BIGINT" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<changeSet id="0622_dds_agent_version" author="CBS">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_dds_agent" columnName="version"/></not></preConditions>
<addColumn tableName="m_dds_agent">
<column name="version" type="BIGINT" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<changeSet id="0622_dds_batch_run_version" author="CBS">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_dds_batch_run" columnName="version"/></not></preConditions>
<addColumn tableName="m_dds_batch_run">
<column name="version" type="BIGINT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="posted_amount" type="DECIMAL(19,6)"/>
</addColumn>
</changeSet>
<changeSet id="0622_dds_collection_entry_version" author="CBS">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_dds_collection_entry" columnName="version"/></not></preConditions>
<addColumn tableName="m_dds_collection_entry">
<column name="version" type="BIGINT" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<!--
What a passbook line records beside the money, as a savings transaction does: the branch it is
booked at, the business day it was recorded (the collection date can be days earlier), and the
journal it was booked under, so a line and its journal entries can be found from each other.
-->
<changeSet id="0622_dds_party_transaction_columns" author="CBS">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_dds_party_transaction" columnName="office_id"/></not></preConditions>
<addColumn tableName="m_dds_party_transaction">
<column name="office_id" type="BIGINT"/>
<column name="submitted_on_date" type="DATE"/>
<column name="journal_transaction_id" type="VARCHAR(100)"/>
</addColumn>
</changeSet>
<changeSet id="0622_dds_party_transaction_backfill" author="CBS">
<sql>
update m_dds_party_transaction t
set office_id = coalesce((select p.office_id from m_dds_party p where p.id = t.party_id),
(select a.office_id from m_dds_agent a where a.id = t.agent_id))
where t.office_id is null;
update m_dds_party_transaction
set submitted_on_date = coalesce(cast(created_date as date), transaction_date)
where submitted_on_date is null;
update m_dds_party_transaction t
set journal_transaction_id = (select ra.journal_transaction_id from m_dds_collection_entry e
join m_dds_receipt_application ra on ra.id = e.receipt_application_id
where e.party_transaction_id = t.id
limit 1)
where t.journal_transaction_id is null;
update acc_gl_journal_entry j
set ref_num = (select max(t.voucher_no) from m_dds_party_transaction t
where t.journal_transaction_id = j.transaction_id)
where j.ref_num is null
and exists (select 1 from m_dds_party_transaction t
where t.journal_transaction_id = j.transaction_id and t.voucher_no is not null);
</sql>
</changeSet>
<changeSet id="0622_dds_party_transaction_not_null" author="CBS">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">select count(*) from m_dds_party_transaction where office_id is null or submitted_on_date is null</sqlCheck>
</preConditions>
<addNotNullConstraint tableName="m_dds_party_transaction" columnName="office_id" columnDataType="BIGINT"/>
<addNotNullConstraint tableName="m_dds_party_transaction" columnName="submitted_on_date" columnDataType="DATE"/>
</changeSet>
<changeSet id="0622_dds_party_transaction_office_fk" author="CBS">
<addForeignKeyConstraint baseTableName="m_dds_party_transaction" baseColumnNames="office_id"
constraintName="fk_dds_party_transaction_office" referencedTableName="m_office"
referencedColumnNames="id"/>
</changeSet>
<!--
Integrity the table never had. An amount is always positive - the direction says which way it
went; the direction is one of the two; the type is one DDS writes. The commission run that paid a
line, and the line a collection entry was settled into, must exist. commission_batch_run_id keeps
its 0 marker (counted before runs were tracked), so it gets no foreign key.
-->
<changeSet id="0622_dds_party_transaction_checks" author="CBS">
<sql>
alter table m_dds_party_transaction add constraint chk_dds_party_txn_amount check (amount &gt; 0);
alter table m_dds_party_transaction add constraint chk_dds_party_txn_direction check (direction in ('CREDIT', 'DEBIT'));
alter table m_dds_party_transaction add constraint chk_dds_party_txn_type check (transaction_type in
('PARTY_TRAN', 'AGENT_TRAN', 'WITHDRAW_DEPOSITE', 'INTEREST_POST', 'OD_INTEREST_POST', 'COMMISSION_POST',
'MATURITY_PAYOUT', 'PREMATURE_INT_RECOVERY', 'PREMATURE_AGENT_TOPUP', 'PREMATURE_PAYOUT'));
</sql>
<addForeignKeyConstraint baseTableName="m_dds_party_transaction" baseColumnNames="commission_paid_batch_run_id"
constraintName="fk_dds_party_txn_commission_paid_run" referencedTableName="m_dds_batch_run"
referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dds_collection_entry" baseColumnNames="party_transaction_id"
constraintName="fk_dds_collection_entry_party_txn" referencedTableName="m_dds_party_transaction"
referencedColumnNames="id"/>
</changeSet>
<!-- Statements and day books read a party lines by date; journals are matched by their id. -->
<changeSet id="0622_dds_party_transaction_indexes" author="CBS">
<createIndex tableName="m_dds_party_transaction" indexName="idx_dds_party_txn_party_date">
<column name="party_id"/>
<column name="transaction_date"/>
<column name="id"/>
</createIndex>
<createIndex tableName="m_dds_party_transaction" indexName="idx_dds_party_txn_journal">
<column name="journal_transaction_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,175 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
The rest of what a savings account keeps that DDS did not.
Who and when, on every DDS row. The audit columns were optional and pointed at nothing; a row
with no maker is a row nobody can be asked about. Every save sets them (AuditorAwareImpl falls
back to the system administrator for a background run), so they become required and must name
a real user. A row saved before that, with none, is put down to the system administrator.
-->
<changeSet id="0623_dds_audit_backfill" author="CBS">
<sql>
update m_dds_party set createdby_id = 1 where createdby_id is null;
update m_dds_party set created_date = coalesce(lastmodified_date, current_timestamp) where created_date is null;
update m_dds_agent set createdby_id = 1 where createdby_id is null;
update m_dds_agent set created_date = coalesce(lastmodified_date, current_timestamp) where created_date is null;
update m_dds_account_type set createdby_id = 1 where createdby_id is null;
update m_dds_account_type set created_date = coalesce(lastmodified_date, current_timestamp) where created_date is null;
update m_dds_party_transaction set createdby_id = 1 where createdby_id is null;
update m_dds_party_transaction set created_date = coalesce(lastmodified_date, cast(transaction_date as timestamp)) where created_date is null;
update m_dds_collection_entry set createdby_id = 1 where createdby_id is null;
update m_dds_collection_entry set created_date = coalesce(lastmodified_date, current_timestamp) where created_date is null;
update m_dds_batch_run set createdby_id = 1 where createdby_id is null;
update m_dds_batch_run set created_date = coalesce(lastmodified_date, current_timestamp) where created_date is null;
</sql>
</changeSet>
<changeSet id="0623_dds_audit_required" author="CBS">
<addNotNullConstraint tableName="m_dds_party" columnName="createdby_id" columnDataType="BIGINT"/>
<addNotNullConstraint tableName="m_dds_party" columnName="created_date" columnDataType="DATETIME"/>
<addNotNullConstraint tableName="m_dds_agent" columnName="createdby_id" columnDataType="BIGINT"/>
<addNotNullConstraint tableName="m_dds_agent" columnName="created_date" columnDataType="DATETIME"/>
<addNotNullConstraint tableName="m_dds_account_type" columnName="createdby_id" columnDataType="BIGINT"/>
<addNotNullConstraint tableName="m_dds_account_type" columnName="created_date" columnDataType="DATETIME"/>
<addNotNullConstraint tableName="m_dds_party_transaction" columnName="createdby_id" columnDataType="BIGINT"/>
<addNotNullConstraint tableName="m_dds_party_transaction" columnName="created_date" columnDataType="DATETIME"/>
<addNotNullConstraint tableName="m_dds_collection_entry" columnName="createdby_id" columnDataType="BIGINT"/>
<addNotNullConstraint tableName="m_dds_collection_entry" columnName="created_date" columnDataType="DATETIME"/>
<addNotNullConstraint tableName="m_dds_batch_run" columnName="createdby_id" columnDataType="BIGINT"/>
<addNotNullConstraint tableName="m_dds_batch_run" columnName="created_date" columnDataType="DATETIME"/>
</changeSet>
<changeSet id="0623_dds_audit_user_fks" author="CBS">
<addForeignKeyConstraint baseTableName="m_dds_party" baseColumnNames="createdby_id" constraintName="fk_dds_party_created_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dds_party" baseColumnNames="lastmodifiedby_id" constraintName="fk_dds_party_modified_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dds_agent" baseColumnNames="createdby_id" constraintName="fk_dds_agent_created_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dds_agent" baseColumnNames="lastmodifiedby_id" constraintName="fk_dds_agent_modified_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dds_account_type" baseColumnNames="createdby_id"
constraintName="fk_dds_account_type_created_by" referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dds_account_type" baseColumnNames="lastmodifiedby_id"
constraintName="fk_dds_account_type_modified_by" referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dds_party_transaction" baseColumnNames="createdby_id"
constraintName="fk_dds_party_txn_created_by" referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dds_party_transaction" baseColumnNames="lastmodifiedby_id"
constraintName="fk_dds_party_txn_modified_by" referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dds_collection_entry" baseColumnNames="createdby_id"
constraintName="fk_dds_collection_created_by" referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dds_collection_entry" baseColumnNames="lastmodifiedby_id"
constraintName="fk_dds_collection_modified_by" referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dds_batch_run" baseColumnNames="createdby_id" constraintName="fk_dds_batch_run_created_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dds_batch_run" baseColumnNames="lastmodifiedby_id"
constraintName="fk_dds_batch_run_modified_by" referencedTableName="m_appuser" referencedColumnNames="id"/>
</changeSet>
<!--
An account is tied to its product by id, as m_savings_account.product_id - not only by the code
in sub_type, which can be renamed. Its currency, and when and by whom it was made operative and
closed, are kept on it as a savings account keeps them. Its branch becomes required: every
account is opened at one (the screen and the API both require it).
-->
<changeSet id="0623_dds_party_columns" author="CBS">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_dds_party" columnName="account_type_id"/></not></preConditions>
<addColumn tableName="m_dds_party">
<column name="account_type_id" type="BIGINT"/>
<column name="currency_code" type="VARCHAR(3)"/>
<column name="activatedon_date" type="DATE"/>
<column name="activatedon_userid" type="BIGINT"/>
<column name="closedon_userid" type="BIGINT"/>
</addColumn>
</changeSet>
<changeSet id="0623_dds_party_backfill" author="CBS">
<sql>
update m_dds_party p
set account_type_id = (select t.id from m_dds_account_type t where t.account_type_code = upper(trim(p.sub_type)))
where p.account_type_id is null;
update m_dds_party
set currency_code = (select code from m_organisation_currency order by id limit 1)
where currency_code is null;
update m_dds_party set activatedon_date = opening_date where activatedon_date is null;
update m_dds_party set activatedon_userid = createdby_id where activatedon_userid is null;
update m_dds_party set closedon_userid = coalesce(lastmodifiedby_id, createdby_id)
where closedon_userid is null and close_date is not null;
update m_dds_party p
set office_id = (select a.office_id from m_dds_agent a where a.id = p.agent_id)
where p.office_id is null;
</sql>
</changeSet>
<changeSet id="0623_dds_party_required" author="CBS">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">select count(*) from m_dds_party where account_type_id is null or currency_code is null or office_id is null</sqlCheck>
</preConditions>
<addNotNullConstraint tableName="m_dds_party" columnName="account_type_id" columnDataType="BIGINT"/>
<addNotNullConstraint tableName="m_dds_party" columnName="currency_code" columnDataType="VARCHAR(3)"/>
<addNotNullConstraint tableName="m_dds_party" columnName="office_id" columnDataType="BIGINT"/>
</changeSet>
<changeSet id="0623_dds_party_fks" author="CBS">
<addForeignKeyConstraint baseTableName="m_dds_party" baseColumnNames="account_type_id" constraintName="fk_dds_party_account_type"
referencedTableName="m_dds_account_type" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dds_party" baseColumnNames="activatedon_userid" constraintName="fk_dds_party_activated_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dds_party" baseColumnNames="closedon_userid" constraintName="fk_dds_party_closed_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<createIndex tableName="m_dds_party" indexName="idx_dds_party_account_type">
<column name="account_type_id"/>
</createIndex>
</changeSet>
<!-- A product's three ledger accounts are required by the API; the table now says so too. -->
<changeSet id="0623_dds_account_type_gl_required" author="CBS">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">select count(*) from m_dds_account_type where deposit_liability_gl_id is null or interest_expense_gl_id is null or commission_expense_gl_id is null</sqlCheck>
</preConditions>
<addNotNullConstraint tableName="m_dds_account_type" columnName="deposit_liability_gl_id" columnDataType="BIGINT"/>
<addNotNullConstraint tableName="m_dds_account_type" columnName="interest_expense_gl_id" columnDataType="BIGINT"/>
<addNotNullConstraint tableName="m_dds_account_type" columnName="commission_expense_gl_id" columnDataType="BIGINT"/>
</changeSet>
<!--
Every passbook line has a reference of its own, unique across DDS, as a savings transaction has
ref_no: what a statement, a complaint or a reconciliation quotes to name exactly one line.
-->
<changeSet id="0623_dds_party_transaction_ref_no" author="CBS">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_dds_party_transaction" columnName="ref_no"/></not></preConditions>
<addColumn tableName="m_dds_party_transaction">
<column name="ref_no" type="VARCHAR(40)"/>
</addColumn>
<sql>
update m_dds_party_transaction set ref_no = 'DDST' || lpad(cast(id as varchar), 20, '0') where ref_no is null;
</sql>
<addNotNullConstraint tableName="m_dds_party_transaction" columnName="ref_no" columnDataType="VARCHAR(40)"/>
<addUniqueConstraint tableName="m_dds_party_transaction" columnNames="ref_no" constraintName="uq_dds_party_txn_ref_no"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
DDS account numbers from a serial of their own, without gaps.
Until now an account was numbered from its row id (DDS + 9-digit id). A row id comes from a
sequence, and a sequence never gives a number back: an opening that failed or was rolled back
spent one, and the accounts read DDS000000002, DDS000000004. The serial is a single row, raised
inside the opening transaction - a rollback undoes the raise, so the next account takes the
number that was not used.
-->
<changeSet id="0624_dds_account_number_serial_table" author="CBS">
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_dds_account_number_serial"/></not></preConditions>
<createTable tableName="m_dds_account_number_serial">
<column name="id" type="INT"><constraints primaryKey="true" nullable="false"/></column>
<column name="last_value" type="BIGINT" defaultValueNumeric="0"><constraints nullable="false"/></column>
</createTable>
<insert tableName="m_dds_account_number_serial">
<column name="id" valueNumeric="1"/>
<column name="last_value" valueNumeric="0"/>
</insert>
</changeSet>
<!--
The accounts already open, renumbered onto the serial in the order they were opened, so the
first account is DDS000000001 and there are no gaps. Their number is stored nowhere else (a
search of every text column found it only on m_dds_party). Done in two steps because the new
numbers overlap the old ones and account_no is unique.
-->
<changeSet id="0624_dds_account_number_renumber" author="CBS">
<sql>
update m_dds_party set account_no = 'TMP' || cast(id as varchar) where account_no is not null;
update m_dds_party p
set account_no = 'DDS' || lpad(cast(r.serial as varchar), 9, '0')
from (select id, row_number() over (order by id) as serial from m_dds_party) r
where r.id = p.id;
update m_dds_account_number_serial set last_value = (select count(*) from m_dds_party) where id = 1;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
A starting DDS agent commission chart, so a new bank can run commission on day one.
Without ACCOUNT slabs the commission run stops for every agent with "no commission slab". These
ten cover every collection from 0 upwards with no gap - each starts a paisa after the last ends,
as the Commission Slab Master now requires - at rates rising with the volume collected. They are
a starting point the bank reviews and corrects on the Commission Slab Master, not its policy.
Only for a tenant with no ACCOUNT slab of its own: a bank that has set up its chart keeps it.
-->
<changeSet id="0625_dds_default_commission_slabs" author="CBS">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">select count(*) from m_dds_commission_slab where slab_type = 'ACCOUNT'</sqlCheck>
</preConditions>
<sql>
insert into m_dds_commission_slab (slab_type, slab_from, slab_to, commission_percent, createdby_id, created_date,
lastmodifiedby_id, lastmodified_date)
values
('ACCOUNT', 0.00, 5000.00, 1.50, 1, current_timestamp, 1, current_timestamp),
('ACCOUNT', 5000.01, 10000.00, 2.00, 1, current_timestamp, 1, current_timestamp),
('ACCOUNT', 10000.01, 25000.00, 2.25, 1, current_timestamp, 1, current_timestamp),
('ACCOUNT', 25000.01, 50000.00, 2.50, 1, current_timestamp, 1, current_timestamp),
('ACCOUNT', 50000.01, 75000.00, 2.75, 1, current_timestamp, 1, current_timestamp),
('ACCOUNT', 75000.01, 100000.00, 3.00, 1, current_timestamp, 1, current_timestamp),
('ACCOUNT', 100000.01, 200000.00, 3.25, 1, current_timestamp, 1, current_timestamp),
('ACCOUNT', 200000.01, 500000.00, 3.50, 1, current_timestamp, 1, current_timestamp),
('ACCOUNT', 500000.01, 1000000.00, 3.75, 1, current_timestamp, 1, current_timestamp),
('ACCOUNT', 1000000.01, 9999999999.00, 4.00, 1, current_timestamp, 1, current_timestamp);
</sql>
</changeSet>
</databaseChangeLog>