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
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:
@@ -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;
|
||||
|
||||
+10
@@ -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);
|
||||
|
||||
|
||||
+23
@@ -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;
|
||||
|
||||
+10
@@ -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";
|
||||
|
||||
|
||||
+6
@@ -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;
|
||||
}
|
||||
|
||||
+54
-5
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+12
@@ -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;
|
||||
}
|
||||
|
||||
+58
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+20
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -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);
|
||||
|
||||
+103
@@ -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())) {
|
||||
|
||||
+83
-60
@@ -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) {
|
||||
|
||||
+14
@@ -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;
|
||||
|
||||
+7
@@ -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) //
|
||||
|
||||
+1
-1
@@ -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());
|
||||
|
||||
+17
@@ -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 })
|
||||
|
||||
+6
@@ -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.
|
||||
|
||||
+69
-12
@@ -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());
|
||||
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+25
-14
@@ -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;
|
||||
}
|
||||
|
||||
+12
-4
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+101
-11
@@ -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;
|
||||
|
||||
+14
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+14
-2
@@ -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>
|
||||
|
||||
+148
@@ -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 > 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>
|
||||
+175
@@ -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>
|
||||
+64
@@ -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>
|
||||
+57
@@ -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>
|
||||
Reference in New Issue
Block a user