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.OrderBy;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import jakarta.persistence.UniqueConstraint;
|
import jakarta.persistence.UniqueConstraint;
|
||||||
|
import jakarta.persistence.Version;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
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") })
|
@Table(name = "m_dds_agent", uniqueConstraints = { @UniqueConstraint(columnNames = { "agent_code" }, name = "agent_code_UNIQUE") })
|
||||||
public class Agent extends AbstractAuditableCustom {
|
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
|
@ManyToOne
|
||||||
@JoinColumn(name = "client_id", nullable = false)
|
@JoinColumn(name = "client_id", nullable = false)
|
||||||
private Client client;
|
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
|
* Corrects this slab, reporting only what actually changed so the audit trail records the
|
||||||
* correction rather than a wholesale rewrite.
|
* 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) {
|
public Map<String, Object> update(final JsonCommand command) {
|
||||||
final Map<String, Object> actualChanges = new LinkedHashMap<>(4);
|
final Map<String, Object> actualChanges = new LinkedHashMap<>(4);
|
||||||
|
|
||||||
|
|||||||
+23
@@ -25,6 +25,7 @@ import jakarta.persistence.FetchType;
|
|||||||
import jakarta.persistence.OneToMany;
|
import jakarta.persistence.OneToMany;
|
||||||
import jakarta.persistence.OrderBy;
|
import jakarta.persistence.OrderBy;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.Version;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -45,6 +46,15 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
|||||||
@Table(name = "m_dds_batch_run")
|
@Table(name = "m_dds_batch_run")
|
||||||
public class BatchRun extends AbstractAuditableCustom {
|
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)
|
@Column(name = "job_type", length = 40, nullable = false)
|
||||||
private String jobType;
|
private String jobType;
|
||||||
|
|
||||||
@@ -87,6 +97,14 @@ public class BatchRun extends AbstractAuditableCustom {
|
|||||||
@Column(name = "rows_posted", nullable = false)
|
@Column(name = "rows_posted", nullable = false)
|
||||||
private Integer rowsPosted = 0;
|
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. */
|
/** Why a run stopped, when it did. A background run has nowhere else to say so. */
|
||||||
@Column(name = "failure_reason", length = 500)
|
@Column(name = "failure_reason", length = 500)
|
||||||
private String failureReason;
|
private String failureReason;
|
||||||
@@ -155,6 +173,11 @@ public class BatchRun extends AbstractAuditableCustom {
|
|||||||
this.rowsPosted = posted;
|
this.rowsPosted = posted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void recordPosted(final int posted, final BigDecimal amount) {
|
||||||
|
this.rowsPosted = posted;
|
||||||
|
this.postedAmount = amount;
|
||||||
|
}
|
||||||
|
|
||||||
public void markPosted(final LocalDate postedOnDate) {
|
public void markPosted(final LocalDate postedOnDate) {
|
||||||
this.status = STATUS_POSTED;
|
this.status = STATUS_POSTED;
|
||||||
this.postedOnDate = postedOnDate;
|
this.postedOnDate = postedOnDate;
|
||||||
|
|||||||
+10
@@ -23,6 +23,7 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.JoinColumn;
|
import jakarta.persistence.JoinColumn;
|
||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.Version;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
@@ -49,6 +50,15 @@ import org.apache.fineract.portfolio.dds.party.domain.Party;
|
|||||||
@Table(name = "m_dds_collection_entry")
|
@Table(name = "m_dds_collection_entry")
|
||||||
public class CollectionEntry extends AbstractAuditableCustom {
|
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. */
|
/** Waiting for a clerk to count the cash behind it. */
|
||||||
public static final String PENDING = "PENDING";
|
public static final String PENDING = "PENDING";
|
||||||
|
|
||||||
|
|||||||
+6
@@ -178,5 +178,11 @@ public final class PartyData implements Serializable {
|
|||||||
private final String nomineeSplitMode;
|
private final String nomineeSplitMode;
|
||||||
private final String status;
|
private final String status;
|
||||||
private final LocalDate closeDate;
|
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;
|
private final Collection<PartyNomineeData> nominees;
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-5
@@ -28,6 +28,7 @@ import jakarta.persistence.OneToMany;
|
|||||||
import jakarta.persistence.OrderBy;
|
import jakarta.persistence.OrderBy;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import jakarta.persistence.UniqueConstraint;
|
import jakarta.persistence.UniqueConstraint;
|
||||||
|
import jakarta.persistence.Version;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.MathContext;
|
import java.math.MathContext;
|
||||||
import java.math.RoundingMode;
|
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") })
|
@Table(name = "m_dds_party", uniqueConstraints = { @UniqueConstraint(columnNames = { "party_code" }, name = "party_code_UNIQUE") })
|
||||||
public class Party extends AbstractAuditableCustom {
|
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. */
|
/** 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");
|
private static final BigDecimal DEFAULT_INTEREST_RATE = new BigDecimal("2");
|
||||||
|
|
||||||
@@ -304,6 +314,29 @@ public class Party extends AbstractAuditableCustom {
|
|||||||
@Column(name = "close_date")
|
@Column(name = "close_date")
|
||||||
private LocalDate closeDate;
|
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)
|
@OneToMany(mappedBy = "party", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||||
@OrderBy("id")
|
@OrderBy("id")
|
||||||
private List<PartyNominee> nominees = new ArrayList<>();
|
private List<PartyNominee> nominees = new ArrayList<>();
|
||||||
@@ -492,10 +525,13 @@ public class Party extends AbstractAuditableCustom {
|
|||||||
this.savingsAccountId = savingsAccountId;
|
this.savingsAccountId = savingsAccountId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Numbers the account from its id, the same way 0514 numbered the accounts already open. */
|
/**
|
||||||
public void assignAccountNo() {
|
* Numbers the account from the DDS account serial (m_dds_account_number_serial), not from its id: an
|
||||||
if (this.accountNo == null && getId() != null) {
|
* id is spent by every save that rolls back, and the bank's account numbers must run without gaps.
|
||||||
this.accountNo = "DDS" + String.format("%09d", getId());
|
*/
|
||||||
|
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
|
* is settled to zero (a full Withdraw Deposite/payout) before it is closed, not closed with money
|
||||||
* still owed to it.
|
* still owed to it.
|
||||||
*/
|
*/
|
||||||
public void close(final LocalDate closeDate) {
|
public void close(final LocalDate closeDate, final Long closedBy) {
|
||||||
this.status = STATUS_CLOSED;
|
this.status = STATUS_CLOSED;
|
||||||
this.closeDate = closeDate;
|
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.io.Serializable;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
@@ -51,4 +52,15 @@ public final class PartyTransactionData implements Serializable {
|
|||||||
private final BigDecimal balanceAfterTransaction;
|
private final BigDecimal balanceAfterTransaction;
|
||||||
/** The DDS voucher this line moved under, e.g. DDS/29092026/0001; empty on lines from before vouchers. */
|
/** The DDS voucher this line moved under, e.g. DDS/29092026/0001; empty on lines from before vouchers. */
|
||||||
private final String voucherNo;
|
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.JoinColumn;
|
||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.PostPersist;
|
import jakarta.persistence.PostPersist;
|
||||||
|
import jakarta.persistence.PrePersist;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
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.organisation.agent.domain.Agent;
|
||||||
import org.apache.fineract.portfolio.dds.party.domain.Party;
|
import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||||
import org.apache.fineract.portfolio.dds.voucher.DdsVoucherScope;
|
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)
|
@Column(name = "balance_after_transaction", scale = 6, precision = 19, nullable = false)
|
||||||
private BigDecimal balanceAfterTransaction;
|
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() {
|
protected PartyTransaction() {
|
||||||
//
|
//
|
||||||
}
|
}
|
||||||
@@ -108,6 +141,31 @@ public class PartyTransaction extends AbstractAuditableCustom {
|
|||||||
this.tokenNo = tokenNo;
|
this.tokenNo = tokenNo;
|
||||||
this.description = description;
|
this.description = description;
|
||||||
this.balanceAfterTransaction = balanceAfterTransaction;
|
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 Set<String> ledgerTransactionIds = new LinkedHashSet<>();
|
||||||
private final List<Long> partyTransactionIds = new ArrayList<>();
|
private final List<Long> partyTransactionIds = new ArrayList<>();
|
||||||
private final List<Line> extraLines = 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() {}
|
private DdsVoucherScope() {}
|
||||||
|
|
||||||
@@ -72,6 +74,24 @@ public final class DdsVoucherScope {
|
|||||||
final DdsVoucherScope scope = CURRENT.get();
|
final DdsVoucherScope scope = CURRENT.get();
|
||||||
if (scope != null && transactionId != null) {
|
if (scope != null && transactionId != null) {
|
||||||
scope.ledgerTransactionIds.add(transactionId);
|
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.
|
// 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 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_PARAMETERS = new HashSet<>(Arrays.asList("slabs", "dateFormat", "locale"));
|
||||||
|
|
||||||
private static final Set<String> SUPPORTED_UPDATE_PARAMETERS = new HashSet<>(
|
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()
|
v.reset().parameterAtIndexArray("slabTo", i).value(extractBigDecimal(slabJson, "slabTo")).notNull()
|
||||||
.zeroOrPositiveAmount();
|
.zeroOrPositiveAmount();
|
||||||
v.reset().parameterAtIndexArray("commissionPercent", i).value(extractBigDecimal(slabJson, "commissionPercent"))
|
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)) {
|
if (this.fromApiJsonHelper.parameterExists("commissionPercent", element)) {
|
||||||
v.reset().parameter("commissionPercent")
|
v.reset().parameter("commissionPercent")
|
||||||
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("commissionPercent", element)).notNull()
|
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("commissionPercent", element)).notNull()
|
||||||
.zeroOrPositiveAmount();
|
.zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
|
||||||
}
|
}
|
||||||
|
|
||||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||||
|
|||||||
+103
@@ -21,16 +21,21 @@ package org.apache.fineract.organisation.commissionslab.service;
|
|||||||
import com.google.gson.JsonArray;
|
import com.google.gson.JsonArray;
|
||||||
import com.google.gson.JsonObject;
|
import com.google.gson.JsonObject;
|
||||||
import jakarta.persistence.PersistenceException;
|
import jakarta.persistence.PersistenceException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
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.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
|
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.CommissionSlab;
|
||||||
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
|
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
|
||||||
import org.apache.fineract.organisation.commissionslab.exception.CommissionSlabNotFoundException;
|
import org.apache.fineract.organisation.commissionslab.exception.CommissionSlabNotFoundException;
|
||||||
@@ -68,6 +73,14 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
|
|||||||
slabs.add(slab);
|
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);
|
this.commissionSlabRepository.saveAllAndFlush(slabs);
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
@@ -93,6 +106,9 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
|
|||||||
|
|
||||||
final CommissionSlab slab = this.commissionSlabRepository.findById(slabId)
|
final CommissionSlab slab = this.commissionSlabRepository.findById(slabId)
|
||||||
.orElseThrow(() -> new CommissionSlabNotFoundException(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);
|
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
|
// 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()) {
|
if (!changes.isEmpty()) {
|
||||||
final List<CommissionSlab> others = this.commissionSlabRepository.findByType(slab.getSlabType()).stream()
|
final List<CommissionSlab> others = this.commissionSlabRepository.findByType(slab.getSlabType()).stream()
|
||||||
.filter(other -> !other.getId().equals(slab.getId())).toList();
|
.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);
|
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);
|
this.commissionSlabRepository.saveAndFlush(slab);
|
||||||
|
if (!moved.isEmpty()) {
|
||||||
|
changes.put("neighboursAdjusted", moved.size());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(slabId).with(changes).build();
|
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
|
* 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.
|
* 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) {
|
private void rejectIfOverlapping(final CommissionSlab candidate, final List<CommissionSlab> existing) {
|
||||||
for (final CommissionSlab other : existing) {
|
for (final CommissionSlab other : existing) {
|
||||||
if (!other.getSlabType().equals(candidate.getSlabType())) {
|
if (!other.getSlabType().equals(candidate.getSlabType())) {
|
||||||
|
|||||||
+83
-60
@@ -270,24 +270,23 @@ public class DdsLedger {
|
|||||||
final String description) {
|
final String description) {
|
||||||
final GLAccount owed;
|
final GLAccount owed;
|
||||||
final Office office;
|
final Office office;
|
||||||
|
Long withdrawalId = null;
|
||||||
|
Long savingsAccountId = null;
|
||||||
if (party.getSavingsAccountId() != null) {
|
if (party.getSavingsAccountId() != null) {
|
||||||
final SavingsAccount account = load(party.getSavingsAccountId());
|
final SavingsAccount account = load(party.getSavingsAccountId());
|
||||||
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
withdrawalId = withdraw(account, date, amount).getId();
|
||||||
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
savingsAccountId = account.getId();
|
||||||
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
|
|
||||||
this.savingsAccountAssembler.getPivotConfigStatus());
|
|
||||||
relockDebits(account, wasBlocked);
|
|
||||||
owed = controlAccountOf(account);
|
owed = controlAccountOf(account);
|
||||||
office = account.office();
|
office = account.office();
|
||||||
} else {
|
} else {
|
||||||
owed = depositLiabilityOf(party);
|
owed = depositLiabilityOf(party);
|
||||||
office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice();
|
office = officeOf(party);
|
||||||
}
|
}
|
||||||
|
|
||||||
creditCurrentAccount(currentAccountId, date, amount, description);
|
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,
|
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 = ?";
|
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);
|
transfer(from.getSavingsAccountId(), to.getSavingsAccountId(), date, amount, description);
|
||||||
} else if (fromSavings) {
|
} else if (fromSavings) {
|
||||||
final SavingsAccount account = load(from.getSavingsAccountId());
|
final SavingsAccount account = load(from.getSavingsAccountId());
|
||||||
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
final SavingsAccountTransaction withdrawal = withdraw(account, date, amount);
|
||||||
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),
|
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) {
|
} else if (toSavings) {
|
||||||
final SavingsAccount account = load(to.getSavingsAccountId());
|
final SavingsAccount account = load(to.getSavingsAccountId());
|
||||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
||||||
@@ -397,7 +392,9 @@ public class DdsLedger {
|
|||||||
final GLAccount fromDeposits = depositLiabilityOf(from);
|
final GLAccount fromDeposits = depositLiabilityOf(from);
|
||||||
final GLAccount toDeposits = depositLiabilityOf(to);
|
final GLAccount toDeposits = depositLiabilityOf(to);
|
||||||
if (fromDeposits.getId().equals(toDeposits.getId())) {
|
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("DR", from, amount, description);
|
||||||
voucherSideOnDeposits("CR", to, amount, description);
|
voucherSideOnDeposits("CR", to, amount, description);
|
||||||
} else {
|
} 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. */
|
/** 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,
|
private void ddsPair(final Party party, final LocalDate date, final BigDecimal amount, final GLAccount debit, final GLAccount credit,
|
||||||
final String description) {
|
final String description) {
|
||||||
final Office office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice();
|
writePair(officeOf(party), organisationCurrency(), partyLegId(party), date, amount, debit, credit, description, null, null);
|
||||||
final String transactionId = "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().substring(0, 8);
|
|
||||||
writePair(office, organisationCurrency(), transactionId, date, amount, debit, credit, description, null, null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -457,8 +452,12 @@ public class DdsLedger {
|
|||||||
if (productCode == null) {
|
if (productCode == null) {
|
||||||
throw new DdsLedgerNotConfiguredException("a DDS product for party " + (party == null ? "?" : party.getPartyCode()));
|
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
|
// By the id the account is tied to; by its code only for an account saved before ids were kept.
|
||||||
+ "where account_type_code = ?", Long.class, productCode);
|
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()) {
|
if (ids.isEmpty()) {
|
||||||
throw new DdsLedgerNotConfiguredException("DDS product " + productCode + " (party " + party.getPartyCode() + ")");
|
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 "
|
+ "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)) "
|
+ "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' "
|
+ "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))";
|
+ "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 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 = ? "
|
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 "
|
+ "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,
|
* 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
|
* in proportion to what they collected for each. The last share takes the rounding, so the shares
|
||||||
* always add up to the amount paid.
|
* always add up to the amount paid.
|
||||||
*
|
*
|
||||||
* Collections counted before payments were tracked by run have no payment marked against them; if
|
* Only collections counted by commission runs before the paying run are its basis: a run counted
|
||||||
* nothing unpaid is left, the agent's counted collections as a whole set the proportions instead.
|
* 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
|
List<Map<String, Object>> basis = this.jdbcTemplate.queryForList(String.format(COMMISSION_BASIS_SQL, UNPAID_COMMISSION_FILTER), // NOSONAR
|
||||||
agentId);
|
agentId, payingRunId);
|
||||||
if (basis.isEmpty()) {
|
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()) {
|
if (basis.isEmpty()) {
|
||||||
throw new DdsLedgerNotConfiguredException("a collection by this agent to tell which DDS product's Agent Commission Expense "
|
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++) {
|
for (int i = 0; i < ids.size(); i++) {
|
||||||
final Long id = ids.get(i);
|
final Long id = ids.get(i);
|
||||||
final BigDecimal share = i == ids.size() - 1 || total.signum() == 0 ? amount.subtract(allotted)
|
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);
|
allotted = allotted.add(share);
|
||||||
if (share.signum() != 0) {
|
if (share.signum() != 0) {
|
||||||
shares.put(accounts.get(id), share);
|
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. */
|
/** 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) {
|
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) {
|
public void reverseInterest(final Long savingsAccountId, final LocalDate date, final BigDecimal amount, final String description) {
|
||||||
final SavingsAccount account = load(savingsAccountId);
|
final SavingsAccount account = load(savingsAccountId);
|
||||||
|
|
||||||
// DDS's own movement, so the debit block comes off for it and goes straight back on.
|
final SavingsAccountTransaction withdrawal = withdraw(account, date, amount);
|
||||||
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),
|
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
|
// 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.
|
// movement is DDS's own, so the block comes off for it and goes straight back on.
|
||||||
final boolean wasBlocked = unlockDebitsIfBlocked(from);
|
final SavingsAccountTransaction withdrawal = withdraw(from, date, amount);
|
||||||
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 deposit = depositWithoutAccounting(to, 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 GLAccount parkIn) {
|
||||||
final SavingsAccount account = load(savingsAccountId);
|
final SavingsAccount account = load(savingsAccountId);
|
||||||
|
|
||||||
// DDS's own movement, so the debit block comes off for it and goes straight back on.
|
final SavingsAccountTransaction withdrawal = withdraw(account, date, amount);
|
||||||
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), parkIn, description,
|
||||||
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
withdrawal.getId(), account.getId());
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -761,20 +750,54 @@ public class DdsLedger {
|
|||||||
private void postPair(final Office office, final String currencyCode, final LocalDate date, final BigDecimal amount,
|
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 GLAccount debit, final GLAccount credit, final String description, final Long savingsTransactionId,
|
||||||
final Long savingsAccountId) {
|
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);
|
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,
|
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 BigDecimal amount, final GLAccount debit, final GLAccount credit, final String description,
|
||||||
final Long savingsTransactionId, final Long savingsAccountId) {
|
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);
|
DdsVoucherScope.recordLedgerTransaction(transactionId);
|
||||||
for (final JournalEntryType type : List.of(JournalEntryType.DEBIT, JournalEntryType.CREDIT)) {
|
this.journalEntryRepository.saveAndFlush(JournalEntry.createNew(office, null, account, currencyCode, transactionId, false, date, type,
|
||||||
this.journalEntryRepository.saveAndFlush(JournalEntry.createNew(office, null,
|
amount, description, PortfolioProductType.SAVING.getValue(), savingsAccountId, null, null, savingsTransactionId, null,
|
||||||
JournalEntryType.DEBIT.equals(type) ? debit : credit, currencyCode, transactionId, false, date, type, amount,
|
null));
|
||||||
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) {
|
private GLAccount controlAccountOf(final SavingsAccount account) {
|
||||||
|
|||||||
+14
@@ -336,6 +336,9 @@ public class DdsVoucherService {
|
|||||||
LAST_ISSUED.set(voucherNo);
|
LAST_ISSUED.set(voucherNo);
|
||||||
|
|
||||||
stamp("update m_dds_party_transaction set voucher_no = ? where id in (%s)", voucherNo, scope.getPartyTransactionIds());
|
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,
|
stamp("update m_savings_account_transaction set voucher_no = ? where voucher_no is null and id in (%s)", voucherNo,
|
||||||
savingsTransactions);
|
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));
|
"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) {
|
private void stamp(final String sql, final String voucherNo, final Collection<Long> ids) {
|
||||||
if (ids.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+7
@@ -115,6 +115,13 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
|
|||||||
changesOnly.put("accountsRepriced", openAccounts.size());
|
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() //
|
return new CommandProcessingResultBuilder() //
|
||||||
.withCommandId(command.commandId()) //
|
.withCommandId(command.commandId()) //
|
||||||
.withEntityId(accountTypeId) //
|
.withEntityId(accountTypeId) //
|
||||||
|
|||||||
+1
-1
@@ -588,7 +588,7 @@ public class DdsApprovalReadService {
|
|||||||
for (final Map<String, Object> runRow : this.jdbcTemplate.queryForList( // NOSONAR
|
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)) {
|
"select agent_id, amount from m_dds_batch_run_row where batch_run_id = ? and amount > 0 order by id", resourceId)) {
|
||||||
try {
|
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) -> {
|
.forEach((account, share) -> {
|
||||||
byAccount.merge(account.getGlCode(), share, BigDecimal::add);
|
byAccount.merge(account.getGlCode(), share, BigDecimal::add);
|
||||||
names.put(account.getGlCode(), account.getName());
|
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.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.DELETE;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.Path;
|
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.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.dds.batch.data.BatchRunData;
|
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.BatchRunReadPlatformService;
|
||||||
|
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@Path("/v1/dds/batchruns")
|
@Path("/v1/dds/batchruns")
|
||||||
@@ -52,6 +55,20 @@ public class BatchRunsApiResource {
|
|||||||
private final BatchRunReadPlatformService readPlatformService;
|
private final BatchRunReadPlatformService readPlatformService;
|
||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
private final DdsVoucherService ddsVoucherService;
|
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
|
@GET
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
|||||||
+6
@@ -30,6 +30,12 @@ public interface BatchRunWritePlatformService {
|
|||||||
|
|
||||||
CommandProcessingResult postBatchRun(Long batchRunId, JsonCommand command);
|
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
|
* 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.
|
* 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.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
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
|
* 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.
|
* figure, so re-posting finds nothing left to move.
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWritePlatformService {
|
public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWritePlatformService {
|
||||||
|
|
||||||
@@ -194,6 +196,9 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
|||||||
default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType);
|
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);
|
this.batchRunRepository.saveAndFlush(batchRun);
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
@@ -202,6 +207,35 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
|||||||
.build();
|
.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
|
* 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
|
* 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 String jobType = batchRun.getJobType();
|
||||||
final LocalDate jobDate = batchRun.getJobDate();
|
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;
|
int done = 0;
|
||||||
|
BigDecimal moved = BigDecimal.ZERO;
|
||||||
for (final BatchRunRow row : batchRun.getRows()) {
|
for (final BatchRunRow row : batchRun.getRows()) {
|
||||||
switch (jobType) {
|
final boolean applied = switch (jobType) {
|
||||||
case INTEREST_CALCULATION -> accrue(row, jobDate, false);
|
case INTEREST_CALCULATION -> {
|
||||||
case OD_INTEREST_CALCULATION -> accrue(row, jobDate, true);
|
accrue(row, jobDate, false);
|
||||||
|
yield true;
|
||||||
|
}
|
||||||
|
case OD_INTEREST_CALCULATION -> {
|
||||||
|
accrue(row, jobDate, true);
|
||||||
|
yield true;
|
||||||
|
}
|
||||||
case INTEREST_POSTING -> postInterest(row, batchRun, false);
|
case INTEREST_POSTING -> postInterest(row, batchRun, false);
|
||||||
case OD_INTEREST_POSTING -> postInterest(row, batchRun, true);
|
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 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);
|
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
|
// 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
|
// 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
|
// 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.
|
// 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
|
* same accrued interest would each list it in full, and paying both would credit interest that
|
||||||
* was never earned.
|
* 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());
|
final Party party = partyOf(row.getPartyId());
|
||||||
if (party.accruedInterest(odInterest).compareTo(row.getAmount()) < 0) {
|
if (party.accruedInterest(odInterest).compareTo(row.getAmount()) < 0) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final BigDecimal newBalance = party.postInterest(row.getAmount(), odInterest);
|
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";
|
final String transactionType = odInterest ? "OD_INTEREST_POST" : "INTEREST_POST";
|
||||||
this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), batchRun.getJobDate(), transactionType,
|
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));
|
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
|
* 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.
|
* 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());
|
final Agent agent = agentOf(row.getAgentId());
|
||||||
// Only what is actually owed can be paid: two posting runs generated over the same owed
|
// 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.
|
// figure would each list it in full, and paying both would pay commission twice.
|
||||||
if (agent.getCommission().compareTo(row.getAmount()) < 0) {
|
if (agent.getCommission().compareTo(row.getAmount()) < 0) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
agent.payOutCommission(row.getAmount());
|
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,
|
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(),
|
null, null, agent.getAgentSavingAccountNo(), row.getAmount(), null, null, "DDS batch run #" + batchRun.getId(),
|
||||||
agent.getAgentCurrentBalance()));
|
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
|
// 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.
|
// 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,
|
this.ddsLedger.payCommission(((Number) account.get("id")).longValue(), batchRun.getJobDate(), amount,
|
||||||
"DDS commission, batch run #" + batchRun.getId(), shares);
|
"DDS commission, batch run #" + batchRun.getId(), shares);
|
||||||
this.ddsLedger.markCommissionPaid(agent.getId(), batchRun.getId());
|
this.ddsLedger.markCommissionPaid(agent.getId(), batchRun.getId());
|
||||||
|
|||||||
+11
@@ -115,6 +115,7 @@ public final class CollectionEntryCommandFromApiJsonDeserializer {
|
|||||||
.inMinMaxRange(1, DdsSchedule.CATCH_UP_DAYS);
|
.inMinMaxRange(1, DdsSchedule.CATCH_UP_DAYS);
|
||||||
} else {
|
} else {
|
||||||
v.reset().parameter(ENTRIES).parameterAtIndexArray("amount", i).value(decimalOf(row)).notNull().positiveAmount();
|
v.reset().parameter(ENTRIES).parameterAtIndexArray("amount", i).value(decimalOf(row)).notNull().positiveAmount();
|
||||||
|
toThePaisa(v, decimalOf(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
final String mode = stringOf(row, "transactionMode");
|
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);
|
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.Agent;
|
||||||
import org.apache.fineract.organisation.agent.domain.AgentRepository;
|
import org.apache.fineract.organisation.agent.domain.AgentRepository;
|
||||||
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
|
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.CollectionEntry;
|
||||||
import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntryRepository;
|
import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntryRepository;
|
||||||
import org.apache.fineract.portfolio.dds.collection.exception.CollectionEntryNotFoundException;
|
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);
|
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
|
// What the customers on DDS's own books are now owed, by the customer-deposits account of their
|
||||||
// their product - two products may owe on two accounts.
|
// product and the branch they bank at - two products owe on two accounts, and a branch's DDS
|
||||||
final Map<Long, BigDecimal> pooled = new LinkedHashMap<>();
|
// Customer Deposits must add up to its own customers' balances.
|
||||||
final Map<Long, GLAccount> depositAccounts = new LinkedHashMap<>();
|
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) {
|
for (final CollectionEntry entry : pending) {
|
||||||
final Party party = collectableParty(entry.getParty().getPartyCode(), agent);
|
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);
|
this.ddsLedger.applyAgentReceiptToSavings(party, settledOn, entry.getAmount(), receiptAccount, description);
|
||||||
} else {
|
} else {
|
||||||
final GLAccount deposits = this.ddsLedger.depositLiabilityOf(party);
|
final GLAccount deposits = this.ddsLedger.depositLiabilityOf(party);
|
||||||
depositAccounts.putIfAbsent(deposits.getId(), deposits);
|
final Office office = this.ddsLedger.officeOf(party);
|
||||||
pooled.merge(deposits.getId(), entry.getAmount(), BigDecimal::add);
|
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 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
|
// The agent's cash in hand is not raised: this cash was counted in at the counter, not
|
||||||
// carried.
|
// carried.
|
||||||
final BigDecimal balanceAfter = party.credit(entry.getAmount());
|
final BigDecimal balanceAfter = party.credit(entry.getAmount());
|
||||||
final PartyTransaction transaction = this.partyTransactionRepository.saveAndFlush(new PartyTransaction(party, agent,
|
final PartyTransaction line = new PartyTransaction(party, agent, entry.getEntryDate(), "PARTY_TRAN", "CREDIT",
|
||||||
entry.getEntryDate(), "PARTY_TRAN", "CREDIT", entry.getTransactionMode(), "GL", receiptAccount.getGlCode(),
|
entry.getTransactionMode(), "GL", receiptAccount.getGlCode(), entry.getAmount(), entry.getChequeNo(),
|
||||||
entry.getAmount(), entry.getChequeNo(), entry.getTokenNo(), description, balanceAfter));
|
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);
|
entry.markSettledByReceipt(settledOn, transaction.getId(), applicationId);
|
||||||
this.collectionEntryRepository.save(entry);
|
this.collectionEntryRepository.save(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everyone on DDS's own accounts, in one pair per customer-deposits account, all under one journal id.
|
// Everyone on DDS's own accounts, in one pair per branch and customer-deposits account, all under one journal id.
|
||||||
final String journalId = "DDSR-A" + applicationId;
|
|
||||||
boolean journalled = false;
|
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) {
|
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);
|
depositAccounts.get(owed.getKey()), journalId, description);
|
||||||
journalled = true;
|
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.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.credit_to_account_type as creditToAccountType, p.credit_to_account as creditToAccount, "
|
||||||
+ " p.nominee_applicable as nomineeApplicable, p.nominee_split_mode as nomineeSplitMode, "
|
+ " 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
|
// 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
|
// whose account type was renamed away or predates the master still lists, just
|
||||||
// without a description.
|
// without a description.
|
||||||
@@ -214,6 +217,11 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
|
|||||||
.nomineeSplitMode(rs.getString("nomineeSplitMode")) //
|
.nomineeSplitMode(rs.getString("nomineeSplitMode")) //
|
||||||
.status(rs.getString("status")) //
|
.status(rs.getString("status")) //
|
||||||
.closeDate(JdbcSupport.getLocalDate(rs, "closeDate")) //
|
.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<>()) //
|
.nominees(new ArrayList<>()) //
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
@@ -345,10 +353,10 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String lastAccountNo() {
|
public String lastAccountNo() {
|
||||||
// The newest account by id - an account's number is given from its id, so the highest id is the
|
// The highest number on the DDS account serial - the account opened last. Closed accounts count:
|
||||||
// one opened last. Closed accounts count: this says what was opened, not what is still open.
|
// this says what was opened, not what is still open.
|
||||||
final List<String> found = this.jdbcTemplate.queryForList( // NOSONAR
|
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);
|
return found.isEmpty() ? null : found.get(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+101
-11
@@ -29,6 +29,7 @@ import java.time.LocalDate;
|
|||||||
import java.time.Period;
|
import java.time.Period;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
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.SavingsAccount;
|
||||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
|
||||||
import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService;
|
import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.orm.jpa.JpaSystemException;
|
import org.springframework.orm.jpa.JpaSystemException;
|
||||||
@@ -133,15 +135,20 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
|||||||
final String partyCode = generateNextPartyCode();
|
final String partyCode = generateNextPartyCode();
|
||||||
final Party party = Party.fromJson(client, agent, officeNamed(command.longValueOfParameterNamed(OFFICE_ID)), partyCode,
|
final Party party = Party.fromJson(client, agent, officeNamed(command.longValueOfParameterNamed(OFFICE_ID)), partyCode,
|
||||||
command);
|
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);
|
linkSavingsAccountIfProvided(party, clientId, command);
|
||||||
addNomineesFromCommand(party, command);
|
addNomineesFromCommand(party, command);
|
||||||
|
|
||||||
// The party is the DDS account: nothing is opened in the savings tables for it. Its
|
// The party is the DDS account: nothing is opened in the savings tables for it. Its number
|
||||||
// number comes from its id, so it is given once the first save has made one.
|
// is the next on the DDS account serial, taken in this transaction - so an opening that
|
||||||
this.partyRepository.saveAndFlush(party);
|
// fails or is rolled back hands its number back, and the numbers run without gaps.
|
||||||
party.assignAccountNo();
|
party.assignAccountNo(nextAccountSerial());
|
||||||
this.partyRepository.saveAndFlush(party);
|
this.partyRepository.saveAndFlush(party);
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
@@ -166,6 +173,10 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
|||||||
this.fromApiJsonDeserializer.validateForUpdate(command.json());
|
this.fromApiJsonDeserializer.validateForUpdate(command.json());
|
||||||
|
|
||||||
final Party partyForUpdate = this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId));
|
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 -
|
// 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
|
// 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);
|
validateCreditToAccount(owningClientId, command);
|
||||||
|
|
||||||
final Map<String, Object> changesOnly = partyForUpdate.update(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
|
// Only when it actually changed: a party already sitting on a since-retired account type
|
||||||
// must stay editable for everything else.
|
// must stay editable for everything else.
|
||||||
if (changesOnly.containsKey("subType")) {
|
if (changesOnly.containsKey("subType")) {
|
||||||
validateSubType((String) changesOnly.get("subType"));
|
validateSubType((String) changesOnly.get("subType"));
|
||||||
// Moved to another scheme: it takes that scheme's rate.
|
// Moved to another scheme: it takes that scheme's rate, and is tied to it by id.
|
||||||
partyForUpdate.takeSchemeRate(schemeOf(partyForUpdate).getInterestRate());
|
final DdsAccountType newScheme = schemeOf(partyForUpdate);
|
||||||
|
partyForUpdate.takeSchemeRate(newScheme.getInterestRate());
|
||||||
|
partyForUpdate.linkProduct(newScheme.getId());
|
||||||
changesOnly.put("interestRate", partyForUpdate.getInterestRate());
|
changesOnly.put("interestRate", partyForUpdate.getInterestRate());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +258,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
|||||||
}
|
}
|
||||||
|
|
||||||
final LocalDate closeDate = command.localDateValueOfParameterNamed("closeDate");
|
final LocalDate closeDate = command.localDateValueOfParameterNamed("closeDate");
|
||||||
party.close(closeDate);
|
party.close(closeDate, currentUserId());
|
||||||
this.partyRepository.saveAndFlush(party);
|
this.partyRepository.saveAndFlush(party);
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId).build();
|
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
|
// 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
|
// that an agent could still collect on. The same as Maturity Auto Close does, on the day the
|
||||||
// money moved.
|
// money moved.
|
||||||
party.close(DateUtils.getBusinessLocalDate());
|
party.close(DateUtils.getBusinessLocalDate(), currentUserId());
|
||||||
this.partyRepository.saveAndFlush(party);
|
this.partyRepository.saveAndFlush(party);
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId).build();
|
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
|
// 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 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.
|
// 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);
|
this.partyRepository.saveAndFlush(party);
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder().withEntityId(partyId).build();
|
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
|
* counted at another. Refuses an id that names no branch rather than leaving the account
|
||||||
* unplaced.
|
* 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) {
|
private Office officeNamed(final Long officeId) {
|
||||||
return this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
|
return this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
|
||||||
}
|
}
|
||||||
@@ -874,7 +950,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
|||||||
|
|
||||||
payOutRemainder(party, command, closeDate, lockinOverrideReason);
|
payOutRemainder(party, command, closeDate, lockinOverrideReason);
|
||||||
|
|
||||||
party.close(closeDate);
|
party.close(closeDate, currentUserId());
|
||||||
this.partyRepository.saveAndFlush(party);
|
this.partyRepository.saveAndFlush(party);
|
||||||
|
|
||||||
final Map<String, Object> changes = new HashMap<>();
|
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() {
|
private String generateNextPartyCode() {
|
||||||
final Long maxSuffix = this.partyRepository.retrieveMaxPartyCodeSuffix();
|
final Long maxSuffix = this.partyRepository.retrieveMaxPartyCodeSuffix();
|
||||||
final long nextSuffix = (maxSuffix == null ? 0L : maxSuffix) + 1L;
|
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.JsonObject;
|
||||||
import com.google.gson.reflect.TypeToken;
|
import com.google.gson.reflect.TypeToken;
|
||||||
import java.lang.reflect.Type;
|
import java.lang.reflect.Type;
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
@@ -161,6 +162,7 @@ public final class PartyTransactionCommandFromApiJsonDeserializer {
|
|||||||
} else {
|
} else {
|
||||||
v.reset().parameter(COLLECTIONS).parameterAtIndexArray("amount", i).value(extractBigDecimal(row, "amount"))
|
v.reset().parameter(COLLECTIONS).parameterAtIndexArray("amount", i).value(extractBigDecimal(row, "amount"))
|
||||||
.notNull().positiveAmount();
|
.notNull().positiveAmount();
|
||||||
|
toThePaisa(v, extractBigDecimal(row, "amount"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,9 +173,11 @@ public final class PartyTransactionCommandFromApiJsonDeserializer {
|
|||||||
.notBlank();
|
.notBlank();
|
||||||
v.reset().parameter("withdrawAmount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("withdrawAmount", element))
|
v.reset().parameter("withdrawAmount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("withdrawAmount", element))
|
||||||
.notNull().positiveAmount();
|
.notNull().positiveAmount();
|
||||||
|
toThePaisa(v, this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("withdrawAmount", element));
|
||||||
} else if ("AGENT_TRAN".equals(transactionType)) {
|
} else if ("AGENT_TRAN".equals(transactionType)) {
|
||||||
v.reset().parameter("amount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("amount", element)).notNull()
|
v.reset().parameter("amount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("amount", element)).notNull()
|
||||||
.positiveAmount();
|
.positiveAmount();
|
||||||
|
toThePaisa(v, this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("amount", element));
|
||||||
|
|
||||||
// An agent handing its round's takings to the branch is a counter transaction like any
|
// 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.
|
// other, so it says how the money arrived on the same terms a collection does.
|
||||||
@@ -207,4 +211,14 @@ public final class PartyTransactionCommandFromApiJsonDeserializer {
|
|||||||
dataValidationErrors);
|
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.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.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.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 "
|
+ " 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
|
@Override
|
||||||
@@ -71,6 +75,14 @@ public class PartyTransactionReadPlatformServiceImpl implements PartyTransaction
|
|||||||
.description(rs.getString("description")) //
|
.description(rs.getString("description")) //
|
||||||
.balanceAfterTransaction(rs.getBigDecimal("balanceAfterTransaction")) //
|
.balanceAfterTransaction(rs.getBigDecimal("balanceAfterTransaction")) //
|
||||||
.voucherNo(rs.getString("voucherNo")) //
|
.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();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,4 +193,8 @@
|
|||||||
<include file="parts/0619_dds_gl_six_digit_codes_and_agent_gl.xml" relativeToChangelogFile="true"/>
|
<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/0620_dds_product_gl_mapping.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0621_dds_product_drop_unused_gl.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>
|
</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