Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6dbe01cfc | ||
|
|
422ea459be | ||
|
|
6e89217e59 |
@@ -28,6 +28,7 @@ import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.OrderBy;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import jakarta.persistence.Version;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
@@ -61,6 +62,15 @@ import org.apache.fineract.portfolio.client.domain.Client;
|
||||
@Table(name = "m_dds_agent", uniqueConstraints = { @UniqueConstraint(columnNames = { "agent_code" }, name = "agent_code_UNIQUE") })
|
||||
public class Agent extends AbstractAuditableCustom {
|
||||
|
||||
/**
|
||||
* Optimistic lock, as a savings account has: two saves of the same row from different requests -
|
||||
* two collections at once, or a collection during a posting run - cannot both win; the second is
|
||||
* refused and retried instead of quietly overwriting the first one's balance.
|
||||
*/
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private Long version;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "client_id", nullable = false)
|
||||
private Client client;
|
||||
|
||||
+10
@@ -69,6 +69,16 @@ public class CommissionSlab extends AbstractAuditableCustom {
|
||||
* Corrects this slab, reporting only what actually changed so the audit trail records the
|
||||
* correction rather than a wholesale rewrite.
|
||||
*/
|
||||
/** Moves where this slab starts - used when the slab before it is corrected, so the two still meet. */
|
||||
public void startAt(final BigDecimal from) {
|
||||
this.slabFrom = from;
|
||||
}
|
||||
|
||||
/** Moves where this slab ends - used when the slab after it is corrected, so the two still meet. */
|
||||
public void endAt(final BigDecimal to) {
|
||||
this.slabTo = to;
|
||||
}
|
||||
|
||||
public Map<String, Object> update(final JsonCommand command) {
|
||||
final Map<String, Object> actualChanges = new LinkedHashMap<>(4);
|
||||
|
||||
|
||||
+10
-1
@@ -41,7 +41,6 @@ public final class DdsAccountTypeData implements Serializable {
|
||||
private final String accountTypeDescription;
|
||||
private final Boolean autoInterestPost;
|
||||
private final String autoInterestPostWhen;
|
||||
private final Integer autoInterestPostDay;
|
||||
/** Whether an account on this scheme closes itself on its maturity date. */
|
||||
private final Boolean maturityAutoClose;
|
||||
/**
|
||||
@@ -56,6 +55,16 @@ public final class DdsAccountTypeData implements Serializable {
|
||||
/** The scheme's annual interest rate, which an account opened on it takes. */
|
||||
private final BigDecimal interestRate;
|
||||
private final Boolean active;
|
||||
/** The product's own ledger accounts (see DdsAccountType), each with its code and name to show it by. */
|
||||
private final Long depositLiabilityGlId;
|
||||
private final String depositLiabilityGlCode;
|
||||
private final String depositLiabilityGlName;
|
||||
private final Long interestExpenseGlId;
|
||||
private final String interestExpenseGlCode;
|
||||
private final String interestExpenseGlName;
|
||||
private final Long commissionExpenseGlId;
|
||||
private final String commissionExpenseGlCode;
|
||||
private final String commissionExpenseGlName;
|
||||
private final LocalDateTime createdDate;
|
||||
private final LocalDateTime lastModifiedDate;
|
||||
}
|
||||
|
||||
+38
-18
@@ -60,10 +60,6 @@ public class DdsAccountType extends AbstractAuditableCustom {
|
||||
@Column(name = "auto_interest_post_when", length = 20)
|
||||
private String autoInterestPostWhen;
|
||||
|
||||
/** Day of the month interest posts on. Only meaningful when {@code autoInterestPost} is set. */
|
||||
@Column(name = "auto_interest_post_day")
|
||||
private Integer autoInterestPostDay;
|
||||
|
||||
/**
|
||||
* Whether an account on this scheme closes itself the day it matures, instead of waiting for a
|
||||
* clerk to pay it out by hand.
|
||||
@@ -119,6 +115,24 @@ public class DdsAccountType extends AbstractAuditableCustom {
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private boolean active;
|
||||
|
||||
/**
|
||||
* The ledger accounts this product posts through, chosen on its Accounting tab the way a savings
|
||||
* product chooses its own. DDS posts through these alone - there is no bank-wide account to fall
|
||||
* back on - so a product missing one stops its postings with a message naming what to set.
|
||||
*
|
||||
* What the bank owes this product's customers - DDS Customer Deposits, or the product's own.
|
||||
*/
|
||||
@Column(name = "deposit_liability_gl_id")
|
||||
private Long depositLiabilityGlId;
|
||||
|
||||
/** What interest paid to this product's customers costs the bank. */
|
||||
@Column(name = "interest_expense_gl_id")
|
||||
private Long interestExpenseGlId;
|
||||
|
||||
/** What the agents' commission on collecting for this product costs the bank. */
|
||||
@Column(name = "commission_expense_gl_id")
|
||||
private Long commissionExpenseGlId;
|
||||
|
||||
protected DdsAccountType() {
|
||||
//
|
||||
}
|
||||
@@ -130,14 +144,14 @@ public class DdsAccountType extends AbstractAuditableCustom {
|
||||
this.autoInterestPost = command.booleanPrimitiveValueOfParameterNamed("autoInterestPost");
|
||||
this.autoInterestPostWhen = StringUtils
|
||||
.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("autoInterestPostWhen"), null);
|
||||
this.autoInterestPostDay = this.autoInterestPost
|
||||
? command.integerValueOfParameterNamedDefaultToNullIfZero("autoInterestPostDay")
|
||||
: null;
|
||||
this.maturityAutoClose = command.booleanPrimitiveValueOfParameterNamed("maturityAutoClose");
|
||||
this.prematureLockinMonths = valueOr(command, "prematureLockinMonths", DEFAULT_LOCKIN_MONTHS);
|
||||
this.prematureInterestReduction = valueOr(command, "prematureInterestReduction", DEFAULT_INTEREST_REDUCTION);
|
||||
this.prematureAgentCommissionPenalty = valueOr(command, "prematureAgentCommissionPenalty", DEFAULT_AGENT_PENALTY);
|
||||
this.interestRate = command.bigDecimalValueOfParameterNamed("interestRate");
|
||||
this.depositLiabilityGlId = command.longValueOfParameterNamed("depositLiabilityGlId");
|
||||
this.interestExpenseGlId = command.longValueOfParameterNamed("interestExpenseGlId");
|
||||
this.commissionExpenseGlId = command.longValueOfParameterNamed("commissionExpenseGlId");
|
||||
// A newly created account type is available for use straight away.
|
||||
this.active = !command.parameterExists("active") || command.booleanPrimitiveValueOfParameterNamed("active");
|
||||
}
|
||||
@@ -212,23 +226,12 @@ public class DdsAccountType extends AbstractAuditableCustom {
|
||||
this.autoInterestPostWhen = StringUtils.defaultIfEmpty(newValue, null);
|
||||
}
|
||||
|
||||
if (command.isChangeInIntegerParameterNamed("autoInterestPostDay", this.autoInterestPostDay)) {
|
||||
final Integer newValue = command.integerValueOfParameterNamedDefaultToNullIfZero("autoInterestPostDay");
|
||||
actualChanges.put("autoInterestPostDay", newValue);
|
||||
this.autoInterestPostDay = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBooleanParameterNamed("maturityAutoClose", this.maturityAutoClose)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("maturityAutoClose");
|
||||
actualChanges.put("maturityAutoClose", newValue);
|
||||
this.maturityAutoClose = newValue;
|
||||
}
|
||||
|
||||
// A posting day only means anything while auto-posting is on.
|
||||
if (!this.autoInterestPost) {
|
||||
this.autoInterestPostDay = null;
|
||||
}
|
||||
|
||||
if (command.isChangeInIntegerParameterNamed("prematureLockinMonths", this.prematureLockinMonths)) {
|
||||
final Integer newValue = command.integerValueOfParameterNamedDefaultToNullIfZero("prematureLockinMonths");
|
||||
actualChanges.put("prematureLockinMonths", newValue);
|
||||
@@ -256,6 +259,23 @@ public class DdsAccountType extends AbstractAuditableCustom {
|
||||
this.interestRate = newValue;
|
||||
}
|
||||
|
||||
// The product's ledger accounts. All three are required; the deserializer refuses a null.
|
||||
if (command.parameterExists("depositLiabilityGlId")
|
||||
&& command.isChangeInLongParameterNamed("depositLiabilityGlId", this.depositLiabilityGlId)) {
|
||||
this.depositLiabilityGlId = command.longValueOfParameterNamed("depositLiabilityGlId");
|
||||
actualChanges.put("depositLiabilityGlId", this.depositLiabilityGlId);
|
||||
}
|
||||
if (command.parameterExists("interestExpenseGlId")
|
||||
&& command.isChangeInLongParameterNamed("interestExpenseGlId", this.interestExpenseGlId)) {
|
||||
this.interestExpenseGlId = command.longValueOfParameterNamed("interestExpenseGlId");
|
||||
actualChanges.put("interestExpenseGlId", this.interestExpenseGlId);
|
||||
}
|
||||
if (command.parameterExists("commissionExpenseGlId")
|
||||
&& command.isChangeInLongParameterNamed("commissionExpenseGlId", this.commissionExpenseGlId)) {
|
||||
this.commissionExpenseGlId = command.longValueOfParameterNamed("commissionExpenseGlId");
|
||||
actualChanges.put("commissionExpenseGlId", this.commissionExpenseGlId);
|
||||
}
|
||||
|
||||
if (command.isChangeInBooleanParameterNamed("active", this.active)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("active");
|
||||
actualChanges.put("active", newValue);
|
||||
|
||||
+23
@@ -25,6 +25,7 @@ import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.OrderBy;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
@@ -45,6 +46,15 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
@Table(name = "m_dds_batch_run")
|
||||
public class BatchRun extends AbstractAuditableCustom {
|
||||
|
||||
/**
|
||||
* Optimistic lock, as a savings account has: two saves of the same row from different requests -
|
||||
* two collections at once, or a collection during a posting run - cannot both win; the second is
|
||||
* refused and retried instead of quietly overwriting the first one's balance.
|
||||
*/
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private Long version;
|
||||
|
||||
@Column(name = "job_type", length = 40, nullable = false)
|
||||
private String jobType;
|
||||
|
||||
@@ -87,6 +97,14 @@ public class BatchRun extends AbstractAuditableCustom {
|
||||
@Column(name = "rows_posted", nullable = false)
|
||||
private Integer rowsPosted = 0;
|
||||
|
||||
/**
|
||||
* What the run actually moved, beside totalAmount - what it was generated to move. They differ when
|
||||
* rows were passed over at posting (the money had already been paid by another run), and the run
|
||||
* says so rather than reading as though the whole total went through.
|
||||
*/
|
||||
@Column(name = "posted_amount", scale = 6, precision = 19)
|
||||
private BigDecimal postedAmount;
|
||||
|
||||
/** Why a run stopped, when it did. A background run has nowhere else to say so. */
|
||||
@Column(name = "failure_reason", length = 500)
|
||||
private String failureReason;
|
||||
@@ -155,6 +173,11 @@ public class BatchRun extends AbstractAuditableCustom {
|
||||
this.rowsPosted = posted;
|
||||
}
|
||||
|
||||
public void recordPosted(final int posted, final BigDecimal amount) {
|
||||
this.rowsPosted = posted;
|
||||
this.postedAmount = amount;
|
||||
}
|
||||
|
||||
public void markPosted(final LocalDate postedOnDate) {
|
||||
this.status = STATUS_POSTED;
|
||||
this.postedOnDate = postedOnDate;
|
||||
|
||||
+10
@@ -23,6 +23,7 @@ import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Getter;
|
||||
@@ -49,6 +50,15 @@ import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||
@Table(name = "m_dds_collection_entry")
|
||||
public class CollectionEntry extends AbstractAuditableCustom {
|
||||
|
||||
/**
|
||||
* Optimistic lock, as a savings account has: two saves of the same row from different requests -
|
||||
* two collections at once, or a collection during a posting run - cannot both win; the second is
|
||||
* refused and retried instead of quietly overwriting the first one's balance.
|
||||
*/
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private Long version;
|
||||
|
||||
/** Waiting for a clerk to count the cash behind it. */
|
||||
public static final String PENDING = "PENDING";
|
||||
|
||||
|
||||
+6
@@ -178,5 +178,11 @@ public final class PartyData implements Serializable {
|
||||
private final String nomineeSplitMode;
|
||||
private final String status;
|
||||
private final LocalDate closeDate;
|
||||
/** The product by id, the account currency, and who opened (made operative) and closed it - as a savings account shows. */
|
||||
private final Long accountTypeId;
|
||||
private final String currencyCode;
|
||||
private final LocalDate activatedOnDate;
|
||||
private final String activatedByUsername;
|
||||
private final String closedByUsername;
|
||||
private final Collection<PartyNomineeData> nominees;
|
||||
}
|
||||
|
||||
+54
-5
@@ -28,6 +28,7 @@ import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.OrderBy;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import jakarta.persistence.Version;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.math.RoundingMode;
|
||||
@@ -60,6 +61,15 @@ import org.apache.fineract.portfolio.client.domain.Client;
|
||||
@Table(name = "m_dds_party", uniqueConstraints = { @UniqueConstraint(columnNames = { "party_code" }, name = "party_code_UNIQUE") })
|
||||
public class Party extends AbstractAuditableCustom {
|
||||
|
||||
/**
|
||||
* Optimistic lock, as a savings account has: two saves of the same row from different requests -
|
||||
* two collections at once, or a collection during a posting run - cannot both win; the second is
|
||||
* refused and retried instead of quietly overwriting the first one's balance.
|
||||
*/
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private Long version;
|
||||
|
||||
/** DDS accounts carry a flat interest rate unless the officer explicitly overrides it at creation. */
|
||||
private static final BigDecimal DEFAULT_INTEREST_RATE = new BigDecimal("2");
|
||||
|
||||
@@ -304,6 +314,29 @@ public class Party extends AbstractAuditableCustom {
|
||||
@Column(name = "close_date")
|
||||
private LocalDate closeDate;
|
||||
|
||||
/**
|
||||
* The DDS product the account is on, by id - subType carries its code for the screens, but a code
|
||||
* can be renamed and an id cannot, so this is what ties the account to its product's terms and
|
||||
* ledger accounts (as m_savings_account.product_id does).
|
||||
*/
|
||||
@Column(name = "account_type_id")
|
||||
private Long accountTypeId;
|
||||
|
||||
/** The account's currency, as a savings account records its own - set from the bank's currency on opening. */
|
||||
@Column(name = "currency_code", length = 3)
|
||||
private String currencyCode;
|
||||
|
||||
/** When the account became operative, and who made it so - the checker, where the opening was approved. */
|
||||
@Column(name = "activatedon_date")
|
||||
private LocalDate activatedOnDate;
|
||||
|
||||
@Column(name = "activatedon_userid")
|
||||
private Long activatedByUserId;
|
||||
|
||||
/** Who closed the account, beside closeDate - when it was closed. */
|
||||
@Column(name = "closedon_userid")
|
||||
private Long closedByUserId;
|
||||
|
||||
@OneToMany(mappedBy = "party", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||
@OrderBy("id")
|
||||
private List<PartyNominee> nominees = new ArrayList<>();
|
||||
@@ -492,10 +525,13 @@ public class Party extends AbstractAuditableCustom {
|
||||
this.savingsAccountId = savingsAccountId;
|
||||
}
|
||||
|
||||
/** Numbers the account from its id, the same way 0514 numbered the accounts already open. */
|
||||
public void assignAccountNo() {
|
||||
if (this.accountNo == null && getId() != null) {
|
||||
this.accountNo = "DDS" + String.format("%09d", getId());
|
||||
/**
|
||||
* Numbers the account from the DDS account serial (m_dds_account_number_serial), not from its id: an
|
||||
* id is spent by every save that rolls back, and the bank's account numbers must run without gaps.
|
||||
*/
|
||||
public void assignAccountNo(final long serial) {
|
||||
if (this.accountNo == null) {
|
||||
this.accountNo = "DDS" + String.format("%09d", serial);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,9 +575,22 @@ public class Party extends AbstractAuditableCustom {
|
||||
* is settled to zero (a full Withdraw Deposite/payout) before it is closed, not closed with money
|
||||
* still owed to it.
|
||||
*/
|
||||
public void close(final LocalDate closeDate) {
|
||||
public void close(final LocalDate closeDate, final Long closedBy) {
|
||||
this.status = STATUS_CLOSED;
|
||||
this.closeDate = closeDate;
|
||||
this.closedByUserId = closedBy;
|
||||
}
|
||||
|
||||
/** Ties the account to its product by id; called whenever subType is set or changed. */
|
||||
public void linkProduct(final Long productId) {
|
||||
this.accountTypeId = productId;
|
||||
}
|
||||
|
||||
/** Stamps the opening: the account's currency, and the day and user that made it operative. */
|
||||
public void recordActivation(final String currency, final LocalDate on, final Long by) {
|
||||
this.currencyCode = currency;
|
||||
this.activatedOnDate = on;
|
||||
this.activatedByUserId = by;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+12
@@ -22,6 +22,7 @@ import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@@ -51,4 +52,15 @@ public final class PartyTransactionData implements Serializable {
|
||||
private final BigDecimal balanceAfterTransaction;
|
||||
/** The DDS voucher this line moved under, e.g. DDS/29092026/0001; empty on lines from before vouchers. */
|
||||
private final String voucherNo;
|
||||
/** The branch the line is booked at, the business day it was recorded, and the journal it was booked under. */
|
||||
private final Long officeId;
|
||||
private final String officeName;
|
||||
private final LocalDate submittedOnDate;
|
||||
private final String journalTransactionId;
|
||||
/** The line's own unique reference (m_dds_party_transaction.ref_no). */
|
||||
private final String refNo;
|
||||
/** Who keyed the line and when, as a savings transaction shows it. */
|
||||
private final String createdByUsername;
|
||||
private final LocalDateTime createdDate;
|
||||
private final String agentName;
|
||||
}
|
||||
|
||||
+58
@@ -23,11 +23,14 @@ import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.PostPersist;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||
import org.apache.fineract.portfolio.dds.voucher.DdsVoucherScope;
|
||||
@@ -87,6 +90,36 @@ public class PartyTransaction extends AbstractAuditableCustom {
|
||||
@Column(name = "balance_after_transaction", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal balanceAfterTransaction;
|
||||
|
||||
/** The branch the line is booked at - the party's, or its agent's for a line with no party - as savings rows carry. */
|
||||
@Column(name = "office_id")
|
||||
private Long officeId;
|
||||
|
||||
/**
|
||||
* The business day the line was recorded, beside transactionDate - the day the money moved. They
|
||||
* differ when a collection is applied from a cashier's receipt days after it was collected.
|
||||
*/
|
||||
@Column(name = "submitted_on_date")
|
||||
private LocalDate submittedOnDate;
|
||||
|
||||
/**
|
||||
* The acc_gl_journal_entry.transaction_id the line was booked under, so a passbook line and its
|
||||
* journal can be found from each other. Null for a line that moves nothing on the books (a transfer
|
||||
* between two customers owed on the same account).
|
||||
*/
|
||||
@Column(name = "journal_transaction_id", length = 100)
|
||||
private String journalTransactionId;
|
||||
|
||||
/**
|
||||
* The line's own reference, unique across DDS, as a savings transaction carries ref_no: what a
|
||||
* statement, a complaint or a reconciliation quotes to name exactly one line.
|
||||
*/
|
||||
@Column(name = "ref_no", length = 40, nullable = false, unique = true)
|
||||
private String refNo;
|
||||
|
||||
/** The voucher the line was printed on - stamped by DdsVoucherService once the operation's voucher is raised. */
|
||||
@Column(name = "voucher_no", length = 30, insertable = false, updatable = false)
|
||||
private String voucherNo;
|
||||
|
||||
protected PartyTransaction() {
|
||||
//
|
||||
}
|
||||
@@ -108,6 +141,31 @@ public class PartyTransaction extends AbstractAuditableCustom {
|
||||
this.tokenNo = tokenNo;
|
||||
this.description = description;
|
||||
this.balanceAfterTransaction = balanceAfterTransaction;
|
||||
if (party != null && party.getOffice() != null) {
|
||||
this.officeId = party.getOffice().getId();
|
||||
} else if (agent != null && agent.getOffice() != null) {
|
||||
this.officeId = agent.getOffice().getId();
|
||||
} else if (agent != null && agent.getClient() != null && agent.getClient().getOffice() != null) {
|
||||
// An agent saved without a branch is still a client of one.
|
||||
this.officeId = agent.getClient().getOffice().getId();
|
||||
}
|
||||
this.submittedOnDate = DateUtils.getBusinessLocalDate();
|
||||
}
|
||||
|
||||
/** Names the journal this line was booked under, where the caller knows it better than the operation's last one. */
|
||||
public PartyTransaction linkJournal(final String transactionId) {
|
||||
this.journalTransactionId = transactionId;
|
||||
return this;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
private void linkToJournal() {
|
||||
if (this.refNo == null) {
|
||||
this.refNo = "DDST" + UUID.randomUUID().toString().replace("-", "").substring(0, 20).toUpperCase();
|
||||
}
|
||||
if (this.journalTransactionId == null) {
|
||||
this.journalTransactionId = DdsVoucherScope.lastLedgerTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+20
@@ -48,6 +48,8 @@ public final class DdsVoucherScope {
|
||||
private final Set<String> ledgerTransactionIds = new LinkedHashSet<>();
|
||||
private final List<Long> partyTransactionIds = new ArrayList<>();
|
||||
private final List<Line> extraLines = new ArrayList<>();
|
||||
/** The journal most recently written in this operation - what the next passbook line saved belongs to. */
|
||||
private String lastLedgerTransactionId;
|
||||
|
||||
private DdsVoucherScope() {}
|
||||
|
||||
@@ -72,6 +74,24 @@ public final class DdsVoucherScope {
|
||||
final DdsVoucherScope scope = CURRENT.get();
|
||||
if (scope != null && transactionId != null) {
|
||||
scope.ledgerTransactionIds.add(transactionId);
|
||||
scope.lastLedgerTransactionId = transactionId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The journal the passbook line being saved now belongs to: every DDS movement writes its journal
|
||||
* first and its passbook line straight after, so it is the last one written in this operation.
|
||||
*/
|
||||
public static String lastLedgerTransaction() {
|
||||
final DdsVoucherScope scope = CURRENT.get();
|
||||
return scope == null ? null : scope.lastLedgerTransactionId;
|
||||
}
|
||||
|
||||
/** For a movement that writes no journal, so its passbook line does not claim the previous one's. */
|
||||
public static void forgetLastLedgerTransaction() {
|
||||
final DdsVoucherScope scope = CURRENT.get();
|
||||
if (scope != null) {
|
||||
scope.lastLedgerTransactionId = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-6
@@ -303,9 +303,13 @@ public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePla
|
||||
+ "join m_savings_product p on p.id = a.product_id "
|
||||
+ "where a.account_no = ? and a.status_enum = 300";
|
||||
|
||||
/** The product DDS opens customers' scheme accounts under - see 0492. */
|
||||
private static final String DDS_DEPOSIT_PRODUCT_SQL = "select value from c_configuration "
|
||||
+ "where name = 'dds-deposit-product' and enabled = true";
|
||||
/**
|
||||
* Whether a savings account is a DDS customer's scheme account - one a DDS party is linked to.
|
||||
* Read off the parties themselves rather than a configured product id: what makes an account a
|
||||
* scheme account is that a scheme runs on it, whatever product it was opened under.
|
||||
*/
|
||||
private static final String DDS_SCHEME_ACCOUNT_SQL = "select count(*) from m_dds_party p "
|
||||
+ "join m_savings_account a on a.id = p.savings_account_id where a.account_no = ?";
|
||||
|
||||
private static final int SAVINGS_DEPOSIT_TYPE = 100;
|
||||
|
||||
@@ -353,9 +357,8 @@ public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePla
|
||||
// A DDS scheme account is a savings account too, so the check above lets it through - but it
|
||||
// belongs to a customer's scheme, and commission paid into it would be counted as that
|
||||
// customer's deposits by every DDS interest run from then on.
|
||||
final Long productId = ((Number) account.get("product_id")).longValue();
|
||||
final List<Long> ddsProduct = this.jdbcTemplate.queryForList(DDS_DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR
|
||||
if (!ddsProduct.isEmpty() && productId.equals(ddsProduct.get(0))) {
|
||||
final Integer schemes = this.jdbcTemplate.queryForObject(DDS_SCHEME_ACCOUNT_SQL, Integer.class, accountNo); // NOSONAR
|
||||
if (schemes != null && schemes > 0) {
|
||||
throw new AgentSavingsAccountNotOperativeException(accountNo, "DDS scheme");
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -44,6 +44,8 @@ public final class CommissionSlabCommandFromApiJsonDeserializer {
|
||||
// A List, not a Set: isOneOfTheseStringValues only lowercases both sides in its List overload.
|
||||
private static final List<String> SUPPORTED_SLAB_TYPES = List.of("ACCOUNT", "COMMISSION");
|
||||
|
||||
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
|
||||
|
||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("slabs", "dateFormat", "locale"));
|
||||
|
||||
private static final Set<String> SUPPORTED_UPDATE_PARAMETERS = new HashSet<>(
|
||||
@@ -93,7 +95,7 @@ public final class CommissionSlabCommandFromApiJsonDeserializer {
|
||||
v.reset().parameterAtIndexArray("slabTo", i).value(extractBigDecimal(slabJson, "slabTo")).notNull()
|
||||
.zeroOrPositiveAmount();
|
||||
v.reset().parameterAtIndexArray("commissionPercent", i).value(extractBigDecimal(slabJson, "commissionPercent"))
|
||||
.notNull().zeroOrPositiveAmount();
|
||||
.notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +136,7 @@ public final class CommissionSlabCommandFromApiJsonDeserializer {
|
||||
if (this.fromApiJsonHelper.parameterExists("commissionPercent", element)) {
|
||||
v.reset().parameter("commissionPercent")
|
||||
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("commissionPercent", element)).notNull()
|
||||
.zeroOrPositiveAmount();
|
||||
.zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
|
||||
}
|
||||
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
|
||||
+103
@@ -21,16 +21,21 @@ package org.apache.fineract.organisation.commissionslab.service;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import jakarta.persistence.PersistenceException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlab;
|
||||
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
|
||||
import org.apache.fineract.organisation.commissionslab.exception.CommissionSlabNotFoundException;
|
||||
@@ -68,6 +73,14 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
|
||||
slabs.add(slab);
|
||||
}
|
||||
|
||||
// The chart each type will have once these are added - what is stored plus this request -
|
||||
// must still read as one unbroken scale from 0.
|
||||
for (final String type : slabs.stream().map(CommissionSlab::getSlabType).distinct().toList()) {
|
||||
final List<CommissionSlab> chart = new ArrayList<>(this.commissionSlabRepository.findByType(type));
|
||||
chart.addAll(slabs.stream().filter(s -> s.getSlabType().equals(type)).toList());
|
||||
rejectBrokenChart(type, chart);
|
||||
}
|
||||
|
||||
this.commissionSlabRepository.saveAllAndFlush(slabs);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
@@ -93,6 +106,9 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
|
||||
|
||||
final CommissionSlab slab = this.commissionSlabRepository.findById(slabId)
|
||||
.orElseThrow(() -> new CommissionSlabNotFoundException(slabId));
|
||||
final String oldType = slab.getSlabType();
|
||||
final BigDecimal oldFrom = slab.getSlabFrom();
|
||||
final BigDecimal oldTo = slab.getSlabTo();
|
||||
final Map<String, Object> changes = slab.update(command);
|
||||
|
||||
// Re-checked after the change, against every other slab of its type. A correction can just as
|
||||
@@ -101,8 +117,38 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
|
||||
if (!changes.isEmpty()) {
|
||||
final List<CommissionSlab> others = this.commissionSlabRepository.findByType(slab.getSlabType()).stream()
|
||||
.filter(other -> !other.getId().equals(slab.getId())).toList();
|
||||
|
||||
// A boundary moved within the same chart takes its neighbour with it, so the two slabs still
|
||||
// meet a paisa apart and the chart stays unbroken: 10,000 -> 12,000 on this slab's To moves
|
||||
// the next slab's From from 10,000.01 to 12,000.01.
|
||||
final List<CommissionSlab> moved = new ArrayList<>();
|
||||
if (oldType.equals(slab.getSlabType())) {
|
||||
for (final CommissionSlab other : others) {
|
||||
if (slab.getSlabTo().compareTo(oldTo) != 0 && other.getSlabFrom().compareTo(oldTo.add(PAISA)) == 0) {
|
||||
other.startAt(slab.getSlabTo().add(PAISA));
|
||||
moved.add(other);
|
||||
}
|
||||
if (slab.getSlabFrom().compareTo(oldFrom) != 0 && other.getSlabTo().compareTo(oldFrom.subtract(PAISA)) == 0) {
|
||||
other.endAt(slab.getSlabFrom().subtract(PAISA));
|
||||
moved.add(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rejectIfOverlapping(slab, others);
|
||||
final List<CommissionSlab> chart = new ArrayList<>(others);
|
||||
chart.add(slab);
|
||||
rejectBrokenChart(slab.getSlabType(), chart);
|
||||
if (!oldType.equals(slab.getSlabType())) {
|
||||
// Moved to the other type: the chart it left must still be whole too.
|
||||
rejectBrokenChart(oldType, this.commissionSlabRepository.findByType(oldType).stream()
|
||||
.filter(other -> !other.getId().equals(slab.getId())).toList());
|
||||
}
|
||||
this.commissionSlabRepository.saveAll(moved);
|
||||
this.commissionSlabRepository.saveAndFlush(slab);
|
||||
if (!moved.isEmpty()) {
|
||||
changes.put("neighboursAdjusted", moved.size());
|
||||
}
|
||||
}
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(slabId).with(changes).build();
|
||||
@@ -116,6 +162,63 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
|
||||
* explain it. Ranges are inclusive at both ends, so touching endpoints count as an overlap: a
|
||||
* slab ending at 50000 and the next starting at 50000 both match exactly 50000.
|
||||
*/
|
||||
/** Collections are rupees and paise, so consecutive slabs meet one paisa apart. */
|
||||
private static final BigDecimal PAISA = new BigDecimal("0.01");
|
||||
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
|
||||
|
||||
/**
|
||||
* A chart the commission run can always read: every slab From <= To, a percentage of 0 to 100, money
|
||||
* to the paisa, starting at 0, and each slab beginning exactly a paisa after the one before ends.
|
||||
*
|
||||
* The run matches from <= collection <= to and stops with "no commission slab" for an agent whose
|
||||
* collection no slab covers - so a gap, or a chart starting above 0, is not a gentler setting, it is
|
||||
* a run that fails. Refused here, when the chart is saved, rather than discovered on payday.
|
||||
*/
|
||||
private void rejectBrokenChart(final String type, final List<CommissionSlab> chart) {
|
||||
final List<CommissionSlab> sorted = chart.stream().sorted(Comparator.comparing(CommissionSlab::getSlabFrom)).toList();
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
for (int i = 0; i < sorted.size(); i++) {
|
||||
final CommissionSlab slab = sorted.get(i);
|
||||
final String range = plain(slab.getSlabFrom()) + " - " + plain(slab.getSlabTo());
|
||||
if (slab.getSlabFrom().compareTo(slab.getSlabTo()) > 0) {
|
||||
errors.add(chartError("from.after.to", type + " slab " + range + ": From is more than To.", "slabFrom"));
|
||||
}
|
||||
if (slab.getSlabFrom().stripTrailingZeros().scale() > 2 || slab.getSlabTo().stripTrailingZeros().scale() > 2) {
|
||||
errors.add(chartError("paise", type + " slab " + range + ": amounts can have at most two decimals (paise).", "slabFrom"));
|
||||
}
|
||||
if (slab.getCommissionPercent().signum() < 0 || slab.getCommissionPercent().compareTo(HUNDRED) > 0) {
|
||||
errors.add(chartError("percent", type + " slab " + range + ": the percentage must be from 0 to 100.",
|
||||
"commissionPercent"));
|
||||
}
|
||||
if (i == 0) {
|
||||
if (slab.getSlabFrom().signum() != 0) {
|
||||
errors.add(chartError("not.from.zero", type + " slabs must start at 0 - nothing covers 0 to "
|
||||
+ plain(slab.getSlabFrom().subtract(PAISA)) + ".", "slabFrom"));
|
||||
}
|
||||
} else {
|
||||
final BigDecimal expected = sorted.get(i - 1).getSlabTo().add(PAISA);
|
||||
if (slab.getSlabFrom().compareTo(expected) > 0) {
|
||||
errors.add(chartError("gap", type + " slabs leave a gap: nothing covers " + plain(expected) + " to "
|
||||
+ plain(slab.getSlabFrom().subtract(PAISA)) + ". Start this slab at " + plain(expected) + ".",
|
||||
"slabFrom"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!errors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException("error.msg.commissionslab.chart.broken",
|
||||
"The " + type + " commission chart would not cover every collection: " + errors.get(0).getDefaultUserMessage(),
|
||||
errors);
|
||||
}
|
||||
}
|
||||
|
||||
private static ApiParameterError chartError(final String code, final String message, final String parameter) {
|
||||
return ApiParameterError.parameterError("error.msg.commissionslab." + code, message, parameter);
|
||||
}
|
||||
|
||||
private static String plain(final BigDecimal value) {
|
||||
return value.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
}
|
||||
|
||||
private void rejectIfOverlapping(final CommissionSlab candidate, final List<CommissionSlab> existing) {
|
||||
for (final CommissionSlab other : existing) {
|
||||
if (!other.getSlabType().equals(candidate.getSlabType())) {
|
||||
|
||||
+37
-6
@@ -29,6 +29,32 @@ public final class ApprovalCounterRegistry {
|
||||
private static final String DDS_ENTITIES = "'PARTY','AGENT','PARTYTRANSACTION','DDSACCOUNTTYPE','COMMISSIONSLAB',"
|
||||
+ "'DDSBATCH','DDSCOLLECTIONENTRY'";
|
||||
|
||||
/** DDS commands held for a checker, as the DDS approval queue reads them. Aliased {@code c}. */
|
||||
private static final String DDS_PENDING = "FROM m_portfolio_command_source c JOIN m_permission p "
|
||||
+ "ON p.code = c.action_name || '_' || c.entity_name AND p.can_maker_checker = true WHERE c.status = 2 ";
|
||||
|
||||
/**
|
||||
* DDS product and DDS account requests, listed under Product Approval and Pending Account with every
|
||||
* other product and account rather than under DDS Approvals or the Checker Inbox. Written for any
|
||||
* alias, since the Checker Inbox counts with {@code aud} and the DDS counts with {@code c}.
|
||||
*/
|
||||
private static String ddsProductRequest(final String alias) {
|
||||
return "(" + alias + ".entity_name = 'DDSACCOUNTTYPE' AND " + alias + ".action_name IN ('CREATE','UPDATE'))";
|
||||
}
|
||||
|
||||
private static String ddsAccountRequest(final String alias) {
|
||||
return "(" + alias + ".entity_name = 'PARTY' AND " + alias + ".action_name IN ('CREATE','UPDATE'))";
|
||||
}
|
||||
|
||||
/**
|
||||
* A DDS agent request of any kind - opening, editing or closing one. An agent has no voucher and no
|
||||
* amount, so these wait under their own Agent Approval tab rather than among the money entries of
|
||||
* DDS Approvals or the Checker Inbox.
|
||||
*/
|
||||
private static String ddsAgentRequest(final String alias) {
|
||||
return "(" + alias + ".entity_name = 'AGENT')";
|
||||
}
|
||||
|
||||
/** Maker-checker commands awaiting a checker, limited to the offices and checker rights the caller has. */
|
||||
private static final String MC_PENDING = "FROM m_portfolio_command_source aud WHERE aud.status = 2 "
|
||||
+ "AND (:ho OR EXISTS (SELECT 1 FROM m_office o2 WHERE o2.id = aud.office_id AND o2.hierarchy LIKE :h)) "
|
||||
@@ -49,7 +75,9 @@ public final class ApprovalCounterRegistry {
|
||||
// ── Fineract's own maker-checker and pending lists ────────────────────────────
|
||||
new ApprovalCounter("checker-inbox", "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) " + MC_PENDING + "AND upper(aud.entity_name) NOT IN (" + PRODUCT_ENTITIES + ") "
|
||||
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_ACCOUNT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE')"),
|
||||
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_ACCOUNT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE') "
|
||||
+ "AND NOT " + ddsProductRequest("aud") + " AND NOT " + ddsAccountRequest("aud")
|
||||
+ " AND NOT " + ddsAgentRequest("aud")),
|
||||
new ApprovalCounter("client-approval", "READ_CLIENT",
|
||||
"SELECT count(*) FROM m_client c WHERE c.status_enum = 100 "
|
||||
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = c.office_id AND o.hierarchy LIKE :h)"),
|
||||
@@ -67,14 +95,16 @@ public final class ApprovalCounterRegistry {
|
||||
+ "AND COALESCE(s.terminate_true, false) = false AND " + CLIENT_OFFICE
|
||||
+ ") + (SELECT count(*) FROM m_current_account ca WHERE ca.status_enum = 100 "
|
||||
+ "AND COALESCE(ca.terminate_true, false) = false "
|
||||
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = ca.office_id AND o.hierarchy LIKE :h))"),
|
||||
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = ca.office_id AND o.hierarchy LIKE :h)) "
|
||||
+ "+ (SELECT count(*) " + DDS_PENDING + "AND " + ddsAccountRequest("c") + ")"),
|
||||
new ApprovalCounter("pending-share-accounts", "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) FROM m_share_account WHERE status_enum = 100"),
|
||||
new ApprovalCounter("product-approval", "ALL_FUNCTIONS_READ",
|
||||
"SELECT (SELECT count(*) FROM m_saving_product_temp WHERE status = 'PENDING' "
|
||||
+ "AND product_type IN ('SAVINGS','CURRENT','SHARE','FD','RD')) + (SELECT count(*) " + MC_PENDING
|
||||
+ "AND upper(aud.entity_name) IN (" + PRODUCT_ENTITIES + ") "
|
||||
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_PRODUCT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE'))"),
|
||||
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_PRODUCT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE')) "
|
||||
+ "+ (SELECT count(*) " + DDS_PENDING + "AND " + ddsProductRequest("c") + ")"),
|
||||
new ApprovalCounter("pending-savings-account-edits", "ALL_FUNCTIONS_READ",
|
||||
"SELECT (SELECT count(*) FROM m_savings_account_pending_edit WHERE status = 'PENDING') "
|
||||
+ "+ (SELECT count(*) FROM m_current_account_pending_edit WHERE status = 'PENDING') "
|
||||
@@ -104,9 +134,10 @@ public final class ApprovalCounterRegistry {
|
||||
new ApprovalCounter(DISBURSEMENT_REQUESTS, "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) FROM m_clerk_disbursement_request WHERE status = 'PENDING'"),
|
||||
new ApprovalCounter("dds-approvals", null,
|
||||
"SELECT count(*) FROM m_portfolio_command_source c JOIN m_permission p "
|
||||
+ "ON p.code = c.action_name || '_' || c.entity_name AND p.can_maker_checker = true "
|
||||
+ "WHERE c.status = 2 AND c.entity_name IN (" + DDS_ENTITIES + ")"),
|
||||
"SELECT count(*) " + DDS_PENDING + "AND c.entity_name IN (" + DDS_ENTITIES + ") "
|
||||
+ "AND NOT " + ddsProductRequest("c") + " AND NOT " + ddsAccountRequest("c")
|
||||
+ " AND NOT " + ddsAgentRequest("c")),
|
||||
new ApprovalCounter("agent-approvals", null, "SELECT count(*) " + DDS_PENDING + "AND " + ddsAgentRequest("c")),
|
||||
new ApprovalCounter("chequebook-requests", "READ_CHEQUEBOOKPENDINGREQUEST",
|
||||
"SELECT count(*) FROM m_chequebook_pending_request WHERE status = 'PENDING'"),
|
||||
new ApprovalCounter("closure-requests", "ALL_FUNCTIONS_READ",
|
||||
|
||||
+5
-3
@@ -53,14 +53,16 @@ public class CustomerSearchApiResource {
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Search for a customer", description = "AND-combines whichever of name/accountNumber/aadhaarNumber/panNumber/address/accountTypes are supplied - at least one is required. accountTypes is a comma-separated list (SAVINGS,CURRENT,FIXED_DEPOSIT,RECURRING_DEPOSIT).")
|
||||
public Collection<CustomerSearchResultData> search(@QueryParam("name") final String name,
|
||||
@Operation(summary = "Search for a customer", description = "AND-combines whichever of clientId/name/mobileNo/accountNumber/aadhaarNumber/panNumber/address/accountTypes are supplied - at least one is required. accountTypes is a comma-separated list (SAVINGS,CURRENT,FIXED_DEPOSIT,RECURRING_DEPOSIT).")
|
||||
public Collection<CustomerSearchResultData> search(@QueryParam("clientId") final String clientId,
|
||||
@QueryParam("name") final String name, @QueryParam("mobileNo") final String mobileNo,
|
||||
@QueryParam("accountNumber") final String accountNumber, @QueryParam("aadhaarNumber") final String aadhaarNumber,
|
||||
@QueryParam("panNumber") final String panNumber, @QueryParam("address") final String address,
|
||||
@QueryParam("accountTypes") final String accountTypes) {
|
||||
final List<String> accountTypeList = accountTypes == null || accountTypes.isBlank() ? List.of()
|
||||
: Arrays.asList(accountTypes.split(","));
|
||||
return safely(() -> this.readPlatformService.search(name, accountNumber, aadhaarNumber, panNumber, address, accountTypeList));
|
||||
return safely(() -> this.readPlatformService.search(clientId, name, mobileNo, accountNumber, aadhaarNumber, panNumber, address,
|
||||
accountTypeList));
|
||||
}
|
||||
|
||||
/** Surfaces IllegalArgumentException as a clean 400 instead of an opaque 500. */
|
||||
|
||||
+4
-3
@@ -27,12 +27,13 @@ public interface CustomerSearchReadPlatformService {
|
||||
/**
|
||||
* Multi-field AND search - only non-blank parameters are applied. At least one must be
|
||||
* supplied. name/address are partial, case-insensitive matches; accountNumber/aadhaarNumber/
|
||||
* panNumber are exact matches (they're precise identifiers, not free text). accountTypes, if
|
||||
* panNumber are exact matches (they're precise identifiers, not free text). clientId is an exact match
|
||||
* on the client's id and mobileNo a partial match on their mobile number. accountTypes, if
|
||||
* non-empty, restricts results to those account types only (SAVINGS/CURRENT/FIXED_DEPOSIT/
|
||||
* RECURRING_DEPOSIT) - a client with no account, or only accounts of other types, is excluded
|
||||
* entirely when this filter is active (unlike the unfiltered case, where a client with no
|
||||
* account at all still appears once for informational purposes).
|
||||
*/
|
||||
Collection<CustomerSearchResultData> search(String name, String accountNumber, String aadhaarNumber, String panNumber,
|
||||
String address, List<String> accountTypes);
|
||||
Collection<CustomerSearchResultData> search(String clientId, String name, String mobileNo, String accountNumber, String aadhaarNumber,
|
||||
String panNumber, String address, List<String> accountTypes);
|
||||
}
|
||||
|
||||
+28
-4
@@ -69,8 +69,11 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
|
||||
""";
|
||||
|
||||
@Override
|
||||
public Collection<CustomerSearchResultData> search(final String name, final String accountNumber, final String aadhaarNumber,
|
||||
final String panNumber, final String address, final List<String> accountTypes) {
|
||||
public Collection<CustomerSearchResultData> search(final String clientId, final String name, final String mobileNo,
|
||||
final String accountNumber, final String aadhaarNumber, final String panNumber, final String address,
|
||||
final List<String> accountTypes) {
|
||||
final boolean hasClientId = clientId != null && !clientId.isBlank();
|
||||
final boolean hasMobile = mobileNo != null && !mobileNo.isBlank();
|
||||
final boolean hasName = name != null && !name.isBlank();
|
||||
final boolean hasAccountNumber = accountNumber != null && !accountNumber.isBlank();
|
||||
final boolean hasAadhaar = aadhaarNumber != null && !aadhaarNumber.isBlank();
|
||||
@@ -79,9 +82,12 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
|
||||
final List<String> effectiveAccountTypes = accountTypes == null ? List.of()
|
||||
: accountTypes.stream().filter(t -> t != null && !t.isBlank()).map(String::trim).map(String::toUpperCase).toList();
|
||||
|
||||
if (!hasName && !hasAccountNumber && !hasAadhaar && !hasPan && !hasAddress && effectiveAccountTypes.isEmpty()) {
|
||||
throw new IllegalArgumentException("Enter at least one search field - name, account number, Aadhaar, PAN, or address.");
|
||||
if (!hasClientId && !hasMobile && !hasName && !hasAccountNumber && !hasAadhaar && !hasPan && !hasAddress
|
||||
&& effectiveAccountTypes.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Enter at least one search field - client ID, name, mobile number, account number, Aadhaar, PAN, or address.");
|
||||
}
|
||||
final Long clientIdValue = hasClientId ? parseClientId(clientId) : null;
|
||||
for (final String type : effectiveAccountTypes) {
|
||||
if (!VALID_ACCOUNT_TYPES.contains(type)) {
|
||||
throw new IllegalArgumentException(
|
||||
@@ -92,10 +98,19 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
|
||||
final StringBuilder sql = new StringBuilder(BASE_SELECT).append(" WHERE 1=1 ");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
|
||||
if (hasClientId) {
|
||||
sql.append(" AND c.id = ? ");
|
||||
params.add(clientIdValue);
|
||||
}
|
||||
if (hasName) {
|
||||
sql.append(" AND LOWER(c.display_name) LIKE LOWER(?) ");
|
||||
params.add("%" + name.trim() + "%");
|
||||
}
|
||||
if (hasMobile) {
|
||||
// Partial, so the last digits of a number find it as well as the whole of it.
|
||||
sql.append(" AND c.mobile_no LIKE ? ");
|
||||
params.add("%" + mobileNo.trim() + "%");
|
||||
}
|
||||
if (hasAccountNumber) {
|
||||
sql.append(" AND acct.account_no = ? ");
|
||||
params.add(accountNumber.trim());
|
||||
@@ -131,6 +146,15 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
|
||||
return this.jdbcTemplate.query(sql.toString(), new CustomerSearchResultMapper(), params.toArray());
|
||||
}
|
||||
|
||||
/** A client id is a whole number; anything else is the caller's mistake to be told about, not a server error. */
|
||||
private static Long parseClientId(final String clientId) {
|
||||
try {
|
||||
return Long.valueOf(clientId.trim());
|
||||
} catch (final NumberFormatException e) {
|
||||
throw new IllegalArgumentException("Client ID must be a number.");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CustomerSearchResultMapper implements RowMapper<CustomerSearchResultData> {
|
||||
@Override
|
||||
public CustomerSearchResultData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||
|
||||
+237
-149
@@ -19,9 +19,13 @@
|
||||
package org.apache.fineract.portfolio.dds.accounting;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||
@@ -83,35 +87,16 @@ import org.springframework.stereotype.Component;
|
||||
@RequiredArgsConstructor
|
||||
public class DdsLedger {
|
||||
|
||||
/** Cash an agent has collected but not yet handed to a branch. Set by 0493. */
|
||||
public static final String AGENT_CASH = "dds-agent-cash-account";
|
||||
|
||||
/** What an agent's commission costs the bank. Set by 0491. */
|
||||
public static final String COMMISSION_EXPENSE = "dds-agent-commission-expense-account";
|
||||
|
||||
/** The savings product DDS opens customer accounts under. Set by 0492. */
|
||||
public static final String DEPOSIT_PRODUCT = "dds-deposit-product";
|
||||
|
||||
/** What DDS customers are owed on accounts that are DDS's own rather than savings accounts. Set by 0516. */
|
||||
public static final String DEPOSIT_LIABILITY = "dds-deposit-liability-account";
|
||||
|
||||
/** What interest on DDS's own accounts costs the bank. Set by 0518. */
|
||||
public static final String INTEREST_EXPENSE = "dds-interest-expense-account";
|
||||
|
||||
private static final String ORGANISATION_CURRENCY_SQL = "select code from m_organisation_currency order by id limit 1";
|
||||
|
||||
private static final String PRODUCT_CURRENCY_SQL = "select currency_code from m_savings_product where id = ?";
|
||||
|
||||
private static final String CONFIGURED_ACCOUNT_SQL = "select value from c_configuration where name = ? and enabled = true";
|
||||
|
||||
/**
|
||||
* The accounts money can be parked in for a customer with no account here. Every bank names its
|
||||
* Sundry account differently, so none is assumed: every open posting liability account is offered
|
||||
* and the clerk searches it by code or name and picks their bank's own. Liability, because the bank
|
||||
* now owes the money - an income or expense account would book it as the bank's own. Posting,
|
||||
* because a header cannot take an entry. And never an account whose balance is meant to equal the
|
||||
* sum of customer or agent accounts - DDS Customer Deposits, a savings or deposit product's control
|
||||
* account, a current-account control, an agent's own cash account - since parked money in any of
|
||||
* sum of customer or agent accounts - a DDS product's customer deposits account, a savings or
|
||||
* deposit product's control account, an agent's own cash account - since parked money in any of
|
||||
* those would leave it no longer adding up.
|
||||
*/
|
||||
private static final String SUNDRY_ACCOUNTS_SQL = "select g.id as \"id\", g.gl_code as \"glCode\", g.name as \"name\" "
|
||||
@@ -120,8 +105,7 @@ public class DdsLedger {
|
||||
+ "and g.id not in (select m.gl_account_id from acc_product_mapping m where m.product_type = 2 "
|
||||
+ "and m.financial_account_type = 2 and m.gl_account_id is not null) "
|
||||
+ "and g.id not in (select a.gl_account_id from m_dds_agent a where a.gl_account_id is not null) "
|
||||
+ "and g.id not in (select c.value from c_configuration c where c.name in ('dds-deposit-liability-account', "
|
||||
+ "'dds-current-account-control-account', 'dds-agent-cash-account') and c.value is not null) "
|
||||
+ "and g.id not in (select t.deposit_liability_gl_id from m_dds_account_type t where t.deposit_liability_gl_id is not null) "
|
||||
+ "order by g.gl_code";
|
||||
|
||||
/**
|
||||
@@ -164,7 +148,7 @@ public class DdsLedger {
|
||||
* Deposits, so a transfer between them writes no leg, yet the slip still has to name both.
|
||||
*/
|
||||
public void voucherSideOnDeposits(final String entryType, final Party party, final BigDecimal amount, final String description) {
|
||||
DdsVoucherScope.addLine(new DdsVoucherScope.Line(entryType, configured(DEPOSIT_LIABILITY).getId(),
|
||||
DdsVoucherScope.addLine(new DdsVoucherScope.Line(entryType, depositLiabilityOf(party).getId(),
|
||||
party.getPartyCode() + " · " + party.getPartyName(), party.getAccountNo(), amount, description));
|
||||
}
|
||||
|
||||
@@ -183,17 +167,6 @@ public class DdsLedger {
|
||||
return deposit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves cash from an agent's hands into a branch. No customer account is involved - the customers
|
||||
* were credited when their money was collected; this is the bank catching up with the cash.
|
||||
*/
|
||||
public void settle(final Office office, final LocalDate date, final BigDecimal amount, final String description,
|
||||
final GLAccount agentCashAccount) {
|
||||
final Long productId = depositProductId();
|
||||
postPair(office, currencyOf(productId), date, amount, productAccount(productId, FUND_SOURCE), agentCashOr(agentCashAccount),
|
||||
description, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the customers of one application their money from the cashier's DDS receipts, in one entry.
|
||||
*
|
||||
@@ -207,11 +180,13 @@ public class DdsLedger {
|
||||
* DR the account the receipts credited the agent's account clears
|
||||
* CR DDS Customer Deposits what the bank now owes the customers
|
||||
* </pre>
|
||||
*
|
||||
* One pair per customer-deposits account rather than one in all: each DDS product names its own
|
||||
* (see depositLiabilityOf), so customers on two products are owed on two accounts.
|
||||
*/
|
||||
public void applyAgentReceipts(final Office office, final LocalDate date, final BigDecimal amount, final GLAccount receiptAccount,
|
||||
final String transactionId, final String description) {
|
||||
writePair(office, organisationCurrency(), transactionId, date, amount, receiptAccount, configured(DEPOSIT_LIABILITY), description,
|
||||
null, null);
|
||||
final GLAccount depositAccount, final String transactionId, final String description) {
|
||||
writePair(office, organisationCurrency(), transactionId, date, amount, receiptAccount, depositAccount, description, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,7 +217,7 @@ public class DdsLedger {
|
||||
collect(party.getSavingsAccountId(), date, amount, description, agentCashAccount);
|
||||
return;
|
||||
}
|
||||
ddsPair(party, date, amount, agentCashOr(agentCashAccount), configured(DEPOSIT_LIABILITY), description);
|
||||
ddsPair(party, date, amount, agentCashOr(agentCashAccount), depositLiabilityOf(party), description);
|
||||
}
|
||||
|
||||
/** Interest onto the party's account: Dr interest expense, Cr what the customer is owed. */
|
||||
@@ -251,7 +226,7 @@ public class DdsLedger {
|
||||
postInterest(party.getSavingsAccountId(), date, amount, description);
|
||||
return;
|
||||
}
|
||||
ddsPair(party, date, amount, configured(INTEREST_EXPENSE), configured(DEPOSIT_LIABILITY), description);
|
||||
ddsPair(party, date, amount, interestExpenseOf(party), depositLiabilityOf(party), description);
|
||||
}
|
||||
|
||||
/** Interest taken back on an early closure - the exact opposite of {@link #postInterest(Party, LocalDate, BigDecimal, String)}. */
|
||||
@@ -260,7 +235,7 @@ public class DdsLedger {
|
||||
reverseInterest(party.getSavingsAccountId(), date, amount, description);
|
||||
return;
|
||||
}
|
||||
ddsPair(party, date, amount, configured(DEPOSIT_LIABILITY), configured(INTEREST_EXPENSE), description);
|
||||
ddsPair(party, date, amount, depositLiabilityOf(party), interestExpenseOf(party), description);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -275,7 +250,7 @@ public class DdsLedger {
|
||||
}
|
||||
final SavingsAccount to = load(toSavingsAccountId);
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(to, date, amount);
|
||||
postPair(to, deposit, date, amount, configured(DEPOSIT_LIABILITY), controlAccountOf(to), description);
|
||||
postPair(to, deposit, date, amount, depositLiabilityOf(party), controlAccountOf(to), description);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -295,24 +270,23 @@ public class DdsLedger {
|
||||
final String description) {
|
||||
final GLAccount owed;
|
||||
final Office office;
|
||||
Long withdrawalId = null;
|
||||
Long savingsAccountId = null;
|
||||
if (party.getSavingsAccountId() != null) {
|
||||
final SavingsAccount account = load(party.getSavingsAccountId());
|
||||
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
||||
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
|
||||
this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
relockDebits(account, wasBlocked);
|
||||
withdrawalId = withdraw(account, date, amount).getId();
|
||||
savingsAccountId = account.getId();
|
||||
owed = controlAccountOf(account);
|
||||
office = account.office();
|
||||
} else {
|
||||
owed = configured(DEPOSIT_LIABILITY);
|
||||
office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice();
|
||||
owed = depositLiabilityOf(party);
|
||||
office = officeOf(party);
|
||||
}
|
||||
|
||||
creditCurrentAccount(currentAccountId, date, amount, description);
|
||||
final String transactionId = "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
final String transactionId = withdrawalId != null ? "S" + withdrawalId : partyLegId(party);
|
||||
writePair(office, organisationCurrency(), transactionId, date, amount, owed, currentAccountControl(currentAccountId), description,
|
||||
null, null);
|
||||
withdrawalId, savingsAccountId);
|
||||
}
|
||||
|
||||
private static final String CURRENT_ACCOUNT_SQL = "select status_enum, account_balance_derived, office_id from m_current_account where id = ?";
|
||||
@@ -329,9 +303,6 @@ public class DdsLedger {
|
||||
+ " join acc_product_mapping apm on apm.product_id = ca.product_id "
|
||||
+ " where ca.id = ? and apm.product_type = 2 and apm.financial_account_type = 2 and apm.payment_type is null";
|
||||
|
||||
private static final String CURRENT_CONTROL_OVERRIDE_SQL = "select value from c_configuration "
|
||||
+ " where name = 'dds-current-account-control-account' and enabled = true";
|
||||
|
||||
/** A deposit row and the balances on the current account, refused unless it is active. */
|
||||
private void creditCurrentAccount(final Long currentAccountId, final LocalDate date, final BigDecimal amount, final String description) {
|
||||
final List<java.util.Map<String, Object>> rows = this.jdbcTemplate.queryForList(CURRENT_ACCOUNT_SQL, currentAccountId); // NOSONAR
|
||||
@@ -348,23 +319,16 @@ public class DdsLedger {
|
||||
this.jdbcTemplate.update(CURRENT_BALANCE_SQL, amount, amount, currentAccountId); // NOSONAR
|
||||
}
|
||||
|
||||
/**
|
||||
* The current account's control account: its product's mapping when that is the only one, and the
|
||||
* setting when the product shares an id - and so its mapping rows - with a savings product.
|
||||
*/
|
||||
/** The current account's control account, from the current product's own accounting. */
|
||||
private GLAccount currentAccountControl(final Long currentAccountId) {
|
||||
final List<Long> ids = this.jdbcTemplate.queryForList(CURRENT_CONTROL_SQL, Long.class, currentAccountId); // NOSONAR
|
||||
if (ids.size() == 1) {
|
||||
return this.glAccountRepository.findById(ids.get(0))
|
||||
.orElseThrow(() -> new DdsLedgerNotConfiguredException("control account for current account " + currentAccountId));
|
||||
}
|
||||
final List<Long> override = this.jdbcTemplate.queryForList(CURRENT_CONTROL_OVERRIDE_SQL, Long.class); // NOSONAR
|
||||
if (!override.isEmpty() && override.get(0) != null) {
|
||||
return this.glAccountRepository.findById(override.get(0))
|
||||
.orElseThrow(() -> new DdsLedgerNotConfiguredException("dds-current-account-control-account"));
|
||||
}
|
||||
throw new DdsLedgerNotConfiguredException(ids.isEmpty() ? "control account for current account " + currentAccountId
|
||||
: "dds-current-account-control-account (the current product's GL mapping is shared with a savings product of the same id)");
|
||||
: "a single control account for current account " + currentAccountId
|
||||
+ " (its current product's GL mapping is shared with a savings product of the same id)");
|
||||
}
|
||||
|
||||
/** The party's balance parked in the Sundry account chosen for it - see sundryAccount. */
|
||||
@@ -374,7 +338,7 @@ public class DdsLedger {
|
||||
payOutToPayable(party.getSavingsAccountId(), date, amount, description, parkIn);
|
||||
return;
|
||||
}
|
||||
ddsPair(party, date, amount, configured(DEPOSIT_LIABILITY), parkIn, description);
|
||||
ddsPair(party, date, amount, depositLiabilityOf(party), parkIn, description);
|
||||
}
|
||||
|
||||
/** The accounts a customer's balance may be parked in, by GL code. None is chosen for the clerk. */
|
||||
@@ -417,44 +381,169 @@ public class DdsLedger {
|
||||
transfer(from.getSavingsAccountId(), to.getSavingsAccountId(), date, amount, description);
|
||||
} else if (fromSavings) {
|
||||
final SavingsAccount account = load(from.getSavingsAccountId());
|
||||
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
||||
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
|
||||
this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
relockDebits(account, wasBlocked);
|
||||
final SavingsAccountTransaction withdrawal = withdraw(account, date, amount);
|
||||
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account),
|
||||
configured(DEPOSIT_LIABILITY), description, null, account.getId());
|
||||
depositLiabilityOf(from), description, withdrawal.getId(), account.getId());
|
||||
} else if (toSavings) {
|
||||
final SavingsAccount account = load(to.getSavingsAccountId());
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
||||
postPair(account, deposit, date, amount, configured(DEPOSIT_LIABILITY), controlAccountOf(account), description);
|
||||
postPair(account, deposit, date, amount, depositLiabilityOf(to), controlAccountOf(account), description);
|
||||
} else {
|
||||
// Both on DDS Customer Deposits: nothing to book, but the slip still names who paid whom.
|
||||
voucherSideOnDeposits("DR", from, amount, description);
|
||||
voucherSideOnDeposits("CR", to, amount, description);
|
||||
final GLAccount fromDeposits = depositLiabilityOf(from);
|
||||
final GLAccount toDeposits = depositLiabilityOf(to);
|
||||
if (fromDeposits.getId().equals(toDeposits.getId())) {
|
||||
// Both owed on the same account: nothing to book, but the slip still names who paid whom -
|
||||
// and the two passbook lines claim no journal, since none was written for them.
|
||||
DdsVoucherScope.forgetLastLedgerTransaction();
|
||||
voucherSideOnDeposits("DR", from, amount, description);
|
||||
voucherSideOnDeposits("CR", to, amount, description);
|
||||
} else {
|
||||
// Two products with customer-deposit accounts of their own: the debt moves from one to the other.
|
||||
ddsPair(from, date, amount, fromDeposits, toDeposits, description);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One pair for a party with no savings account: at the party's own branch, in the bank's currency. */
|
||||
private void ddsPair(final Party party, final LocalDate date, final BigDecimal amount, final GLAccount debit, final GLAccount credit,
|
||||
final String description) {
|
||||
final Office office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice();
|
||||
final String transactionId = "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
writePair(office, organisationCurrency(), transactionId, date, amount, debit, credit, description, null, null);
|
||||
writePair(officeOf(party), organisationCurrency(), partyLegId(party), date, amount, debit, credit, description, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which account plays the agent's part on an entry: the agent's own, or the scheme's shared one
|
||||
* when they have none.
|
||||
*
|
||||
* Only the account differs. Both sides of the entry, their direction and the amount are the same
|
||||
* either way - an agent with their own account is not accounted for differently, only separately.
|
||||
* That is what makes the shared account a safe fallback rather than a second behaviour: an agent
|
||||
* set up before this existed posts exactly as they always did, and a branch can move agents onto
|
||||
* their own accounts one at a time.
|
||||
* The account playing the agent's part on an entry - the agent's own. An agent cannot be saved
|
||||
* without one, so a missing one here is an agent set up before that was required.
|
||||
*/
|
||||
private GLAccount agentCashOr(final GLAccount agentCashAccount) {
|
||||
return agentCashAccount != null ? agentCashAccount : configured(AGENT_CASH);
|
||||
if (agentCashAccount == null) {
|
||||
throw new DdsLedgerNotConfiguredException("the agent's GL account (Organization > Agent Master)");
|
||||
}
|
||||
return agentCashAccount;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// A party's product's own ledger accounts.
|
||||
//
|
||||
// Each DDS product names the accounts it posts through on its Accounting tab (0620), as a savings
|
||||
// product does, and those are the only accounts DDS posts through - there is no bank-wide account
|
||||
// behind them. A product that has not named one stops the posting with a message saying which
|
||||
// account to set on which product, rather than the money landing somewhere nobody chose.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Columns of m_dds_account_type a product's account may be read from - never anything a caller typed. */
|
||||
private static final Set<String> PRODUCT_GL_COLUMNS = Set.of("deposit_liability_gl_id", "interest_expense_gl_id",
|
||||
"commission_expense_gl_id");
|
||||
|
||||
/** What the bank owes this party - their product's customer-deposits account. */
|
||||
public GLAccount depositLiabilityOf(final Party party) {
|
||||
return productAccount(party, "deposit_liability_gl_id", "DDS Customer Deposits");
|
||||
}
|
||||
|
||||
/** What this party's interest costs the bank - their product's interest-expense account. */
|
||||
public GLAccount interestExpenseOf(final Party party) {
|
||||
return productAccount(party, "interest_expense_gl_id", "Interest Expense");
|
||||
}
|
||||
|
||||
private GLAccount productAccount(final Party party, final String column, final String label) {
|
||||
if (!PRODUCT_GL_COLUMNS.contains(column)) {
|
||||
throw new IllegalArgumentException(column);
|
||||
}
|
||||
final String productCode = party == null || party.getSubType() == null ? null : party.getSubType().trim().toUpperCase();
|
||||
if (productCode == null) {
|
||||
throw new DdsLedgerNotConfiguredException("a DDS product for party " + (party == null ? "?" : party.getPartyCode()));
|
||||
}
|
||||
// By the id the account is tied to; by its code only for an account saved before ids were kept.
|
||||
final List<Long> ids = party.getAccountTypeId() != null
|
||||
? this.jdbcTemplate.queryForList("select " + column + " from m_dds_account_type where id = ?", Long.class, // NOSONAR
|
||||
party.getAccountTypeId())
|
||||
: this.jdbcTemplate.queryForList("select " + column + " from m_dds_account_type where account_type_code = ?", // NOSONAR
|
||||
Long.class, productCode);
|
||||
if (ids.isEmpty()) {
|
||||
throw new DdsLedgerNotConfiguredException("DDS product " + productCode + " (party " + party.getPartyCode() + ")");
|
||||
}
|
||||
return accountNamed(ids.get(0), productCode, label);
|
||||
}
|
||||
|
||||
private GLAccount accountNamed(final Long glAccountId, final String productCode, final String label) {
|
||||
final Optional<GLAccount> account = glAccountId == null ? Optional.empty() : this.glAccountRepository.findById(glAccountId);
|
||||
return account.orElseThrow(() -> new DdsLedgerNotConfiguredException(
|
||||
"the " + label + " account on DDS product " + productCode + " (Products > DDS Products > Edit > Accounting)"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// An agent's commission, by product.
|
||||
//
|
||||
// Commission is paid to an agent in one sum for a round that may collect for several products, and
|
||||
// each product books its share on its own commission account. The share is each product's part of
|
||||
// the collections the commission was earned on: those a commission run has counted and no payment
|
||||
// has yet covered. Paying marks them, so the next payment is shared out on the next collections.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
private static final String COMMISSION_BASIS_SQL = "select upper(trim(p.sub_type)) as \"product\", t.commission_expense_gl_id as \"glId\", "
|
||||
+ "sum(pt.amount) as \"volume\" from m_dds_party_transaction pt join m_dds_party p on p.id = pt.party_id "
|
||||
+ "left join m_dds_account_type t on t.account_type_code = upper(trim(p.sub_type)) "
|
||||
+ "where pt.agent_id = ? and pt.transaction_type = 'PARTY_TRAN' and pt.direction = 'CREDIT' "
|
||||
+ "and pt.commission_batch_run_id is not null and pt.commission_batch_run_id < ? %s group by upper(trim(p.sub_type)), t.commission_expense_gl_id "
|
||||
+ "order by upper(trim(p.sub_type))";
|
||||
|
||||
private static final String UNPAID_COMMISSION_FILTER = "and pt.commission_paid_batch_run_id is null";
|
||||
|
||||
private static final String MARK_COMMISSION_PAID_SQL = "update m_dds_party_transaction set commission_paid_batch_run_id = ? "
|
||||
+ "where agent_id = ? and transaction_type = 'PARTY_TRAN' and direction = 'CREDIT' and commission_batch_run_id is not null "
|
||||
+ "and commission_batch_run_id < ? and commission_paid_batch_run_id is null";
|
||||
|
||||
/**
|
||||
* An agent's commission split across the commission accounts of the products they collected for,
|
||||
* in proportion to what they collected for each. The last share takes the rounding, so the shares
|
||||
* always add up to the amount paid.
|
||||
*
|
||||
* Only collections counted by commission runs before the paying run are its basis: a run counted
|
||||
* after this payment was generated earned commission this payment does not include. Collections
|
||||
* counted before payments were tracked by run have no payment marked against them; if nothing
|
||||
* unpaid is left, the agent's counted collections as a whole set the proportions instead.
|
||||
*/
|
||||
public Map<GLAccount, BigDecimal> commissionSplit(final Long agentId, final BigDecimal amount, final Long payingRunId) {
|
||||
List<Map<String, Object>> basis = this.jdbcTemplate.queryForList(String.format(COMMISSION_BASIS_SQL, UNPAID_COMMISSION_FILTER), // NOSONAR
|
||||
agentId, payingRunId);
|
||||
if (basis.isEmpty()) {
|
||||
basis = this.jdbcTemplate.queryForList(String.format(COMMISSION_BASIS_SQL, ""), agentId, payingRunId); // NOSONAR
|
||||
}
|
||||
if (basis.isEmpty()) {
|
||||
throw new DdsLedgerNotConfiguredException("a collection by this agent to tell which DDS product's Agent Commission Expense "
|
||||
+ "account the commission belongs to");
|
||||
}
|
||||
|
||||
final Map<Long, GLAccount> accounts = new LinkedHashMap<>();
|
||||
final Map<Long, BigDecimal> volumes = new LinkedHashMap<>();
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (final Map<String, Object> row : basis) {
|
||||
final String product = (String) row.get("product");
|
||||
final Long glId = row.get("glId") == null ? null : ((Number) row.get("glId")).longValue();
|
||||
final GLAccount account = accountNamed(glId, product, "Agent Commission Expense");
|
||||
final BigDecimal volume = (BigDecimal) row.get("volume");
|
||||
accounts.putIfAbsent(account.getId(), account);
|
||||
volumes.merge(account.getId(), volume, BigDecimal::add);
|
||||
total = total.add(volume);
|
||||
}
|
||||
|
||||
final Map<GLAccount, BigDecimal> shares = new LinkedHashMap<>();
|
||||
BigDecimal allotted = BigDecimal.ZERO;
|
||||
final List<Long> ids = List.copyOf(volumes.keySet());
|
||||
for (int i = 0; i < ids.size(); i++) {
|
||||
final Long id = ids.get(i);
|
||||
final BigDecimal share = i == ids.size() - 1 || total.signum() == 0 ? amount.subtract(allotted)
|
||||
: amount.multiply(volumes.get(id)).divide(total, 2, RoundingMode.HALF_UP);
|
||||
allotted = allotted.add(share);
|
||||
if (share.signum() != 0) {
|
||||
shares.put(accounts.get(id), share);
|
||||
}
|
||||
}
|
||||
return shares;
|
||||
}
|
||||
|
||||
/** Marks the collections a commission payment was shared out on, so the next payment starts after them. */
|
||||
public void markCommissionPaid(final Long agentId, final Long batchRunId) {
|
||||
this.jdbcTemplate.update(MARK_COMMISSION_PAID_SQL, batchRunId, agentId, batchRunId); // NOSONAR
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -490,15 +579,9 @@ public class DdsLedger {
|
||||
public void reverseInterest(final Long savingsAccountId, final LocalDate date, final BigDecimal amount, final String description) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
|
||||
// DDS's own movement, so the debit block comes off for it and goes straight back on.
|
||||
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
||||
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
|
||||
this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
relockDebits(account, wasBlocked);
|
||||
|
||||
final SavingsAccountTransaction withdrawal = withdraw(account, date, amount);
|
||||
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account),
|
||||
productAccount(account.productId(), INTEREST_ON_SAVINGS), description, null, account.getId());
|
||||
productAccount(account.productId(), INTEREST_ON_SAVINGS), description, withdrawal.getId(), account.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -515,15 +598,15 @@ public class DdsLedger {
|
||||
|
||||
// The scheme account is blocked for debit so nobody can take the money out at a counter. This
|
||||
// movement is DDS's own, so the block comes off for it and goes straight back on.
|
||||
final boolean wasBlocked = unlockDebitsIfBlocked(from);
|
||||
final SavingsTransactionBooleanValues withdrawalFlags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||
this.savingsAccountDomainService.handleWithdrawal(from, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, withdrawalFlags,
|
||||
this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
relockDebits(from, wasBlocked);
|
||||
|
||||
final SavingsAccountTransaction withdrawal = withdraw(from, date, amount);
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(to, date, amount);
|
||||
|
||||
postPair(to, deposit, date, amount, controlAccountOf(from), controlAccountOf(to), description);
|
||||
// One leg on each account, each carrying its own account transaction, so both sides of the
|
||||
// movement can be traced from the journal - not only the deposit.
|
||||
writeLeg(from.office(), from.getCurrency().getCode(), "S" + withdrawal.getId(), date, amount, controlAccountOf(from),
|
||||
JournalEntryType.DEBIT, description, withdrawal.getId(), from.getId());
|
||||
writeLeg(from.office(), from.getCurrency().getCode(), "S" + withdrawal.getId(), date, amount, controlAccountOf(to),
|
||||
JournalEntryType.CREDIT, description, deposit.getId(), to.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -556,15 +639,9 @@ public class DdsLedger {
|
||||
final GLAccount parkIn) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
|
||||
// DDS's own movement, so the debit block comes off for it and goes straight back on.
|
||||
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
||||
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
|
||||
this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
relockDebits(account, wasBlocked);
|
||||
|
||||
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), parkIn,
|
||||
description, null, account.getId());
|
||||
final SavingsAccountTransaction withdrawal = withdraw(account, date, amount);
|
||||
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), parkIn, description,
|
||||
withdrawal.getId(), account.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -592,12 +669,18 @@ public class DdsLedger {
|
||||
return deposit;
|
||||
}
|
||||
|
||||
/** Credits an agent's own savings account with commission the bank owes them. */
|
||||
/**
|
||||
* Credits an agent's own savings account with commission the bank owes them - one deposit to the
|
||||
* agent, and one entry per product on that product's commission account (see commissionSplit).
|
||||
*/
|
||||
public SavingsAccountTransaction payCommission(final Long savingsAccountId, final LocalDate date, final BigDecimal amount,
|
||||
final String description) {
|
||||
final String description, final Map<GLAccount, BigDecimal> shares) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
||||
postPair(account, deposit, date, amount, configured(COMMISSION_EXPENSE), controlAccountOf(account), description);
|
||||
final GLAccount control = controlAccountOf(account);
|
||||
for (final Map.Entry<GLAccount, BigDecimal> share : shares.entrySet()) {
|
||||
postPair(account, deposit, date, share.getValue(), share.getKey(), control, description);
|
||||
}
|
||||
return deposit;
|
||||
}
|
||||
|
||||
@@ -667,47 +750,60 @@ public class DdsLedger {
|
||||
private void postPair(final Office office, final String currencyCode, final LocalDate date, final BigDecimal amount,
|
||||
final GLAccount debit, final GLAccount credit, final String description, final Long savingsTransactionId,
|
||||
final Long savingsAccountId) {
|
||||
final String transactionId = savingsTransactionId == null ? "DDS" + date + amount : "S" + savingsTransactionId;
|
||||
final String transactionId = savingsTransactionId == null ? "DDS-" + UUID.randomUUID() : "S" + savingsTransactionId;
|
||||
writePair(office, currencyCode, transactionId, date, amount, debit, credit, description, savingsTransactionId, savingsAccountId);
|
||||
}
|
||||
|
||||
private void writePair(final Office office, final String currencyCode, final String transactionId, final LocalDate date,
|
||||
final BigDecimal amount, final GLAccount debit, final GLAccount credit, final String description,
|
||||
final Long savingsTransactionId, final Long savingsAccountId) {
|
||||
// Noted for the voucher of the operation this leg belongs to - see DdsVoucherService.
|
||||
DdsVoucherScope.recordLedgerTransaction(transactionId);
|
||||
for (final JournalEntryType type : List.of(JournalEntryType.DEBIT, JournalEntryType.CREDIT)) {
|
||||
this.journalEntryRepository.saveAndFlush(JournalEntry.createNew(office, null,
|
||||
JournalEntryType.DEBIT.equals(type) ? debit : credit, currencyCode, transactionId, false, date, type, amount,
|
||||
description, PortfolioProductType.SAVING.getValue(), savingsAccountId, null, null, savingsTransactionId, null, null));
|
||||
}
|
||||
writeLeg(office, currencyCode, transactionId, date, amount, debit, JournalEntryType.DEBIT, description, savingsTransactionId,
|
||||
savingsAccountId);
|
||||
writeLeg(office, currencyCode, transactionId, date, amount, credit, JournalEntryType.CREDIT, description, savingsTransactionId,
|
||||
savingsAccountId);
|
||||
}
|
||||
|
||||
/** A ledger account named by a setting, so a bank can point it at one of their own. */
|
||||
private GLAccount configured(final String settingName) {
|
||||
final List<Long> ids = this.jdbcTemplate.queryForList(CONFIGURED_ACCOUNT_SQL, Long.class, settingName); // NOSONAR
|
||||
if (ids.isEmpty() || ids.get(0) == null) {
|
||||
throw new DdsLedgerNotConfiguredException(settingName);
|
||||
}
|
||||
return this.glAccountRepository.findById(ids.get(0)).orElseThrow(() -> new DdsLedgerNotConfiguredException(settingName));
|
||||
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);
|
||||
this.journalEntryRepository.saveAndFlush(JournalEntry.createNew(office, null, account, currencyCode, transactionId, false, date, type,
|
||||
amount, description, PortfolioProductType.SAVING.getValue(), savingsAccountId, null, null, savingsTransactionId, null,
|
||||
null));
|
||||
}
|
||||
|
||||
/**
|
||||
* A journal id for a leg on DDS's own books - the party's code to find it by and a full UUID to keep it
|
||||
* unique: DDS-PT0003-1b4e... Never built from the date and amount, which two parties can share.
|
||||
*/
|
||||
private static String partyLegId(final Party party) {
|
||||
return "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
||||
}
|
||||
|
||||
/** The branch a party's legs are booked at: the party's own, or its agent's for a party opened without one. */
|
||||
public Office officeOf(final Party party) {
|
||||
return party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice();
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes money off a scheme account that DDS keeps blocked for debits, as DDS's own movement - the block
|
||||
* comes off for it and goes straight back on - and returns the withdrawal so its journal can name it.
|
||||
*/
|
||||
private SavingsAccountTransaction withdraw(final SavingsAccount account, final LocalDate date, final BigDecimal amount) {
|
||||
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
||||
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||
final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(account,
|
||||
DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags, this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
relockDebits(account, wasBlocked);
|
||||
return withdrawal;
|
||||
}
|
||||
|
||||
private GLAccount controlAccountOf(final SavingsAccount account) {
|
||||
return productAccount(account.productId(), CONTROL_ACCOUNT);
|
||||
}
|
||||
|
||||
/**
|
||||
* The savings product DDS opens customer accounts under. A settlement touches no single customer,
|
||||
* so its ledger accounts and currency come from the product rather than from an account.
|
||||
*/
|
||||
private Long depositProductId() {
|
||||
final List<Long> ids = this.jdbcTemplate.queryForList(CONFIGURED_ACCOUNT_SQL, Long.class, DEPOSIT_PRODUCT); // NOSONAR
|
||||
if (ids.isEmpty() || ids.get(0) == null) {
|
||||
throw new DdsLedgerNotConfiguredException(DEPOSIT_PRODUCT);
|
||||
}
|
||||
return ids.get(0);
|
||||
}
|
||||
|
||||
/** The bank's own currency, for an entry that belongs to no savings product. */
|
||||
private String organisationCurrency() {
|
||||
final List<String> codes = this.jdbcTemplate.queryForList(ORGANISATION_CURRENCY_SQL, String.class); // NOSONAR
|
||||
@@ -717,14 +813,6 @@ public class DdsLedger {
|
||||
return codes.get(0);
|
||||
}
|
||||
|
||||
/** Read off the product rather than assumed, so a bank on another currency is not silently wrong. */
|
||||
private String currencyOf(final Long productId) {
|
||||
final List<String> codes = this.jdbcTemplate.queryForList(PRODUCT_CURRENCY_SQL, String.class, productId); // NOSONAR
|
||||
if (codes.isEmpty()) {
|
||||
throw new DdsLedgerNotConfiguredException("currency for savings product " + productId);
|
||||
}
|
||||
return codes.get(0);
|
||||
}
|
||||
|
||||
/** An account off the savings product's own mapping, so this stays right per product. */
|
||||
private GLAccount productAccount(final Long productId, final int financialAccountType) {
|
||||
|
||||
+24
-7
@@ -22,6 +22,7 @@ import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
@@ -335,6 +336,9 @@ public class DdsVoucherService {
|
||||
LAST_ISSUED.set(voucherNo);
|
||||
|
||||
stamp("update m_dds_party_transaction set voucher_no = ? where id in (%s)", voucherNo, scope.getPartyTransactionIds());
|
||||
// The journal legs carry the voucher too, in ref_num, so the slip, the passbook lines and the
|
||||
// journal can each be found from the others - as a savings voucher's legs can.
|
||||
stampLegs(voucherNo, scope.getLedgerTransactionIds());
|
||||
stamp("update m_savings_account_transaction set voucher_no = ? where voucher_no is null and id in (%s)", voucherNo,
|
||||
savingsTransactions);
|
||||
}
|
||||
@@ -356,7 +360,7 @@ public class DdsVoucherService {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
final Long depositGlId = depositLiabilityGlId();
|
||||
final Set<Long> depositGlIds = depositLiabilityGlIds();
|
||||
final Map<String, Map<String, Object>> parties = new LinkedHashMap<>();
|
||||
for (final Map<String, Object> leg : this.jdbcTemplate.queryForList(String.format(LEGS_SQL, placeholders(transactionIds)), // NOSONAR
|
||||
transactionIds.toArray())) {
|
||||
@@ -383,7 +387,7 @@ public class DdsVoucherService {
|
||||
}
|
||||
|
||||
final Matcher partyLeg = PARTY_LEG.matcher(String.valueOf(leg.get("transactionId")));
|
||||
if (partyLeg.matches() && glId.equals(depositGlId)) {
|
||||
if (partyLeg.matches() && depositGlIds.contains(glId)) {
|
||||
final String partyCode = partyLeg.group(1);
|
||||
final Map<String, Object> party = parties.computeIfAbsent(partyCode,
|
||||
code -> this.jdbcTemplate.queryForList(PARTY_SQL, code).stream().findFirst().orElse(Map.of())); // NOSONAR
|
||||
@@ -417,11 +421,24 @@ public class DdsVoucherService {
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** The GL that carries what DDS customers are owed - a leg on it is the customer's own. */
|
||||
private Long depositLiabilityGlId() {
|
||||
final List<Long> ids = this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select value from c_configuration where name = 'dds-deposit-liability-account' and enabled = true", Long.class);
|
||||
return ids.isEmpty() ? null : ids.get(0);
|
||||
/**
|
||||
* The GLs that carry what DDS customers are owed - a leg on one is the customer's own. Each DDS
|
||||
* product names its own on its Accounting tab (0620).
|
||||
*/
|
||||
private Set<Long> depositLiabilityGlIds() {
|
||||
return new HashSet<>(this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"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) {
|
||||
|
||||
+2
-6
@@ -27,11 +27,7 @@ public interface DdsAccountTypeRepository extends JpaRepository<DdsAccountType,
|
||||
|
||||
Optional<DdsAccountType> findByAccountTypeCode(String accountTypeCode);
|
||||
|
||||
/**
|
||||
* Schemes that post interest on their own. A posting day is required to act on one, so a scheme
|
||||
* left without a day is not picked up - it would otherwise mean "post monthly, some day".
|
||||
*/
|
||||
@Query("select t from DdsAccountType t where t.active = true and t.autoInterestPost = true "
|
||||
+ "and t.autoInterestPostDay is not null order by t.accountTypeCode")
|
||||
/** Schemes that post interest on their own, at the end of each posting period. */
|
||||
@Query("select t from DdsAccountType t where t.active = true and t.autoInterestPost = true order by t.accountTypeCode")
|
||||
List<DdsAccountType> findDueForAutoInterestPost();
|
||||
}
|
||||
|
||||
+22
-5
@@ -39,9 +39,15 @@ import org.springframework.stereotype.Component;
|
||||
@Component
|
||||
public final class DdsAccountTypeCommandFromApiJsonDeserializer {
|
||||
|
||||
/*
|
||||
* "autoInterestPostDay" is still accepted, and ignored, so a change held for approval before the
|
||||
* posting day was retired - or an older screen still sending it - is not refused. Interest now
|
||||
* posts at the end of each period, so there is no day to set.
|
||||
*/
|
||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("accountTypeGroup", "accountTypeCode",
|
||||
"accountTypeDescription", "autoInterestPost", "autoInterestPostWhen", "autoInterestPostDay", "maturityAutoClose", "active",
|
||||
"prematureLockinMonths", "prematureInterestReduction", "prematureAgentCommissionPenalty", "interestRate",
|
||||
"depositLiabilityGlId", "interestExpenseGlId", "commissionExpenseGlId",
|
||||
"dateFormat", "locale"));
|
||||
|
||||
/** A rate, not an amount - nothing here may exceed the whole of what it is taken from. */
|
||||
@@ -66,6 +72,14 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer {
|
||||
v.reset().parameter("accountTypeDescription")
|
||||
.value(this.fromApiJsonHelper.extractStringNamed("accountTypeDescription", element)).notBlank()
|
||||
.notExceedingLengthOf(200);
|
||||
// Mandatory, as a savings product's control and interest accounts are: every posting for the
|
||||
// product's customers and agents goes through them, with nothing bank-wide behind them.
|
||||
v.reset().parameter("depositLiabilityGlId").value(this.fromApiJsonHelper.extractLongNamed("depositLiabilityGlId", element))
|
||||
.notNull().integerGreaterThanZero();
|
||||
v.reset().parameter("interestExpenseGlId").value(this.fromApiJsonHelper.extractLongNamed("interestExpenseGlId", element))
|
||||
.notNull().integerGreaterThanZero();
|
||||
v.reset().parameter("commissionExpenseGlId").value(this.fromApiJsonHelper.extractLongNamed("commissionExpenseGlId", element))
|
||||
.notNull().integerGreaterThanZero();
|
||||
// Mandatory: every account opened on the scheme takes this rate.
|
||||
v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element))
|
||||
.notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
|
||||
@@ -99,6 +113,14 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer {
|
||||
.notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
|
||||
}
|
||||
|
||||
// Once named, a product's customer and interest accounts can be changed but not cleared.
|
||||
for (final String required : List.of("depositLiabilityGlId", "interestExpenseGlId", "commissionExpenseGlId")) {
|
||||
if (this.fromApiJsonHelper.parameterExists(required, element)) {
|
||||
v.reset().parameter(required).value(this.fromApiJsonHelper.extractLongNamed(required, element)).notNull()
|
||||
.integerGreaterThanZero();
|
||||
}
|
||||
}
|
||||
|
||||
validateOptionalFields(element, v);
|
||||
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
@@ -121,11 +143,6 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer {
|
||||
.value(this.fromApiJsonHelper.extractStringNamed("autoInterestPostWhen", element)).ignoreIfNull()
|
||||
.notExceedingLengthOf(20);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("autoInterestPostDay", element)) {
|
||||
v.reset().parameter("autoInterestPostDay")
|
||||
.value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("autoInterestPostDay", element)).ignoreIfNull()
|
||||
.inMinMaxRange(1, 31);
|
||||
}
|
||||
|
||||
// Premature closure terms. Zero is allowed throughout - a scheme may close early from day
|
||||
// one, or charge the customer or the agent nothing - so these are floored at zero, not at one.
|
||||
|
||||
+21
-4
@@ -43,15 +43,24 @@ public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeRead
|
||||
public String schema() {
|
||||
return " t.id as id, t.account_type_group as accountTypeGroup, t.account_type_code as accountTypeCode, "
|
||||
+ " t.account_type_description as accountTypeDescription, t.auto_interest_post as autoInterestPost, "
|
||||
+ " t.auto_interest_post_when as autoInterestPostWhen, t.auto_interest_post_day as autoInterestPostDay, "
|
||||
+ " t.auto_interest_post_when as autoInterestPostWhen, "
|
||||
+ " t.maturity_auto_close as maturityAutoClose, "
|
||||
+ " t.premature_lockin_months as prematureLockinMonths, "
|
||||
+ " t.premature_interest_reduction as prematureInterestReduction, "
|
||||
+ " t.premature_agent_commission_penalty as prematureAgentCommissionPenalty, "
|
||||
+ " t.interest_rate as interestRate, "
|
||||
+ " t.is_active as active, "
|
||||
+ " t.created_date as createdDate, t.lastmodified_date as lastModifiedDate "
|
||||
+ " from m_dds_account_type t";
|
||||
+ " t.created_date as createdDate, t.lastmodified_date as lastModifiedDate, "
|
||||
+ " t.deposit_liability_gl_id as depositLiabilityGlId, dl.gl_code as depositLiabilityGlCode, "
|
||||
+ " dl.name as depositLiabilityGlName, "
|
||||
+ " t.interest_expense_gl_id as interestExpenseGlId, ie.gl_code as interestExpenseGlCode, "
|
||||
+ " ie.name as interestExpenseGlName, "
|
||||
+ " t.commission_expense_gl_id as commissionExpenseGlId, ce.gl_code as commissionExpenseGlCode, "
|
||||
+ " ce.name as commissionExpenseGlName "
|
||||
+ " from m_dds_account_type t "
|
||||
+ " left join acc_gl_account dl on dl.id = t.deposit_liability_gl_id "
|
||||
+ " left join acc_gl_account ie on ie.id = t.interest_expense_gl_id "
|
||||
+ " left join acc_gl_account ce on ce.id = t.commission_expense_gl_id";
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -63,13 +72,21 @@ public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeRead
|
||||
.accountTypeDescription(rs.getString("accountTypeDescription")) //
|
||||
.autoInterestPost(rs.getBoolean("autoInterestPost")) //
|
||||
.autoInterestPostWhen(rs.getString("autoInterestPostWhen")) //
|
||||
.autoInterestPostDay(JdbcSupport.getInteger(rs, "autoInterestPostDay")) //
|
||||
.maturityAutoClose(rs.getBoolean("maturityAutoClose")) //
|
||||
.prematureLockinMonths(JdbcSupport.getInteger(rs, "prematureLockinMonths")) //
|
||||
.prematureInterestReduction(rs.getBigDecimal("prematureInterestReduction")) //
|
||||
.prematureAgentCommissionPenalty(rs.getBigDecimal("prematureAgentCommissionPenalty")) //
|
||||
.interestRate(rs.getBigDecimal("interestRate")) //
|
||||
.active(rs.getBoolean("active")) //
|
||||
.depositLiabilityGlId(JdbcSupport.getLong(rs, "depositLiabilityGlId")) //
|
||||
.depositLiabilityGlCode(rs.getString("depositLiabilityGlCode")) //
|
||||
.depositLiabilityGlName(rs.getString("depositLiabilityGlName")) //
|
||||
.interestExpenseGlId(JdbcSupport.getLong(rs, "interestExpenseGlId")) //
|
||||
.interestExpenseGlCode(rs.getString("interestExpenseGlCode")) //
|
||||
.interestExpenseGlName(rs.getString("interestExpenseGlName")) //
|
||||
.commissionExpenseGlId(JdbcSupport.getLong(rs, "commissionExpenseGlId")) //
|
||||
.commissionExpenseGlCode(rs.getString("commissionExpenseGlCode")) //
|
||||
.commissionExpenseGlName(rs.getString("commissionExpenseGlName")) //
|
||||
.createdDate(localDateTime(rs, "createdDate")) //
|
||||
.lastModifiedDate(localDateTime(rs, "lastModifiedDate")) //
|
||||
.build();
|
||||
|
||||
+63
@@ -19,6 +19,7 @@
|
||||
package org.apache.fineract.portfolio.dds.accounttype.service;
|
||||
|
||||
import jakarta.persistence.PersistenceException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -35,7 +36,10 @@ import org.apache.fineract.portfolio.dds.accounttype.exception.DdsAccountTypeNot
|
||||
import org.apache.fineract.portfolio.dds.accounttype.serialization.DdsAccountTypeCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.orm.jpa.JpaSystemException;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -46,6 +50,15 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
|
||||
private final DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer;
|
||||
private final DdsAccountTypeRepository ddsAccountTypeRepository;
|
||||
private final PartyRepository partyRepository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
private static final String GL_SQL = "select classification_enum, account_usage, disabled, gl_code, name from acc_gl_account where id = ?";
|
||||
|
||||
/** acc_gl_account.classification_enum: 2 liability, 5 expense. */
|
||||
private static final int LIABILITY = 2;
|
||||
private static final int EXPENSE = 5;
|
||||
/** acc_gl_account.account_usage 1 - a detail account an entry can be posted to, not a header. */
|
||||
private static final int DETAIL = 1;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -53,6 +66,7 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
|
||||
|
||||
try {
|
||||
this.fromApiJsonDeserializer.validateForCreate(command.json());
|
||||
validateLedgerAccounts(command);
|
||||
|
||||
final DdsAccountType accountType = DdsAccountType.fromJson(command);
|
||||
this.ddsAccountTypeRepository.saveAndFlush(accountType);
|
||||
@@ -77,6 +91,7 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
|
||||
|
||||
try {
|
||||
this.fromApiJsonDeserializer.validateForUpdate(command.json());
|
||||
validateLedgerAccounts(command);
|
||||
|
||||
final DdsAccountType accountType = this.ddsAccountTypeRepository.findById(accountTypeId)
|
||||
.orElseThrow(() -> new DdsAccountTypeNotFoundException(accountTypeId));
|
||||
@@ -100,6 +115,13 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
|
||||
changesOnly.put("accountsRepriced", openAccounts.size());
|
||||
}
|
||||
|
||||
// A renamed product: the accounts on it - tied to it by id - show the new code.
|
||||
if (changesOnly.containsKey("accountTypeCode")) {
|
||||
final int moved = this.jdbcTemplate.update("update m_dds_party set sub_type = ? where account_type_id = ?", // NOSONAR
|
||||
accountType.getAccountTypeCode(), accountTypeId);
|
||||
changesOnly.put("accountsRenamed", moved);
|
||||
}
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(accountTypeId) //
|
||||
@@ -115,6 +137,47 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Each ledger account a product names must be one an entry can land on, and of the kind its job
|
||||
* needs - the same rule the savings product's Accounting tab applies by offering only those. The
|
||||
* customers' money is owed, so a liability; their interest and the agents' commission are costs, so
|
||||
* expenses.
|
||||
*/
|
||||
private void validateLedgerAccounts(final JsonCommand command) {
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
checkLedgerAccount(command, "depositLiabilityGlId", "Customer Deposits", List.of(LIABILITY), errors);
|
||||
checkLedgerAccount(command, "interestExpenseGlId", "Interest Expense", List.of(EXPENSE), errors);
|
||||
checkLedgerAccount(command, "commissionExpenseGlId", "Agent Commission Expense", List.of(EXPENSE), errors);
|
||||
if (!errors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
|
||||
}
|
||||
}
|
||||
|
||||
private void checkLedgerAccount(final JsonCommand command, final String parameter, final String label, final List<Integer> kinds,
|
||||
final List<ApiParameterError> errors) {
|
||||
final Long glId = command.parameterExists(parameter) ? command.longValueOfParameterNamed(parameter) : null;
|
||||
if (glId == null) {
|
||||
return;
|
||||
}
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(GL_SQL, glId); // NOSONAR
|
||||
final String code = "error.msg.dds.account.type." + parameter + ".invalid";
|
||||
if (rows.isEmpty()) {
|
||||
errors.add(ApiParameterError.parameterError(code, label + ": GL account " + glId + " does not exist.", parameter));
|
||||
return;
|
||||
}
|
||||
final Map<String, Object> gl = rows.get(0);
|
||||
final String name = gl.get("name") + " (" + gl.get("gl_code") + ")";
|
||||
if (Boolean.TRUE.equals(gl.get("disabled"))) {
|
||||
errors.add(ApiParameterError.parameterError(code, label + ": " + name + " is disabled.", parameter));
|
||||
} else if (((Number) gl.get("account_usage")).intValue() != DETAIL) {
|
||||
errors.add(ApiParameterError.parameterError(code, label + ": " + name + " is a header account and cannot take entries.",
|
||||
parameter));
|
||||
} else if (!kinds.contains(((Number) gl.get("classification_enum")).intValue())) {
|
||||
errors.add(ApiParameterError.parameterError(code, label + ": " + name + " is not "
|
||||
+ (kinds.get(0) == LIABILITY ? "a liability" : "an expense") + " account.", parameter));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleDataIntegrityIssues(final Throwable realCause, final Exception dve) {
|
||||
if (realCause.getMessage() != null && realCause.getMessage().contains("account_type_code")) {
|
||||
throw new PlatformDataIntegrityException("error.msg.dds.account.type.duplicate.code",
|
||||
|
||||
+3
-2
@@ -43,7 +43,8 @@ public class DdsAccountTypeConfiguration {
|
||||
@ConditionalOnMissingBean(DdsAccountTypeWritePlatformService.class)
|
||||
public DdsAccountTypeWritePlatformService ddsAccountTypeWritePlatformService(
|
||||
DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer, DdsAccountTypeRepository ddsAccountTypeRepository,
|
||||
PartyRepository partyRepository) {
|
||||
return new DdsAccountTypeWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, ddsAccountTypeRepository, partyRepository);
|
||||
PartyRepository partyRepository, JdbcTemplate jdbcTemplate) {
|
||||
return new DdsAccountTypeWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, ddsAccountTypeRepository, partyRepository,
|
||||
jdbcTemplate);
|
||||
}
|
||||
}
|
||||
|
||||
+496
-8
@@ -26,9 +26,12 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.exception.CommandNotAwaitingApprovalException;
|
||||
import org.apache.fineract.commands.exception.CommandNotFoundException;
|
||||
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
|
||||
import org.apache.fineract.portfolio.dds.accounting.exception.DdsLedgerNotConfiguredException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -63,6 +66,7 @@ public class DdsApprovalReadService {
|
||||
private static final String REF_KEY_PREFIX = "DDS_COMMAND:";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final DdsLedger ddsLedger;
|
||||
|
||||
public List<Map<String, Object>> list(final String status, final String fromDate, final String toDate) {
|
||||
final StringBuilder sql = new StringBuilder(ENTRIES_SQL).append(" and c.status = ? ");
|
||||
@@ -95,9 +99,423 @@ public class DdsApprovalReadService {
|
||||
final Map<String, Object> row = describe(found.get(0));
|
||||
row.put("description", text(jsonOf(found.get(0).get("json")), "withdrawDescription"));
|
||||
row.put("lines", linesFor(row, found.get(0)));
|
||||
row.put("fields", fieldsFor(row, found.get(0)));
|
||||
if (isAgentRequest(row)) {
|
||||
row.put("guarantors", guarantorsOf(jsonOf(found.get(0).get("json"))));
|
||||
}
|
||||
if (isPartyRequest(row)) {
|
||||
final JsonObject partyJson = jsonOf(found.get(0).get("json"));
|
||||
row.put("nominees", nomineesOf(partyJson));
|
||||
row.put("nomineeSplit", text(partyJson, "nomineeSplitMode"));
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/** A DDS account (party) being opened or changed - the entries whose review shows the account and its nominees. */
|
||||
private static boolean isPartyRequest(final Map<String, Object> row) {
|
||||
return "PARTY".equals(row.get("entity")) && ("CREATE".equals(row.get("action")) || "UPDATE".equals(row.get("action")));
|
||||
}
|
||||
|
||||
/** What a DDS account request carries, in the order the Party Master Entry screen asks for it. */
|
||||
private record PartyField(String parameter, String label, String group, String kind) {}
|
||||
|
||||
private static final String PARTY_GROUP_CUSTOMER = "Customer & Scheme";
|
||||
private static final String PARTY_GROUP_PERSONAL = "Personal & Contact";
|
||||
private static final String PARTY_GROUP_MINOR = "Minor / Guardian";
|
||||
private static final String PARTY_GROUP_TERMS = "Scheme Terms";
|
||||
private static final String PARTY_GROUP_STATUS = "Balances & Status";
|
||||
private static final String PARTY_GROUP_PAYOUT = "Maturity Payout";
|
||||
|
||||
private static final List<PartyField> PARTY_FIELDS = List.of( //
|
||||
new PartyField("clientId", "Client", PARTY_GROUP_CUSTOMER, "client"), //
|
||||
new PartyField("subType", "Scheme", PARTY_GROUP_CUSTOMER, "scheme"), //
|
||||
new PartyField("agentId", "Agent", PARTY_GROUP_CUSTOMER, "agent"), //
|
||||
new PartyField("officeId", "Branch", PARTY_GROUP_CUSTOMER, "office"), //
|
||||
new PartyField("nominal", "Nominal", PARTY_GROUP_CUSTOMER, "yesno"), //
|
||||
new PartyField("partyName", "Party Name", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("partyName1", "Party Name 1", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("address1", "Address 1", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("address2", "Address 2", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("address3", "Address 3", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("city", "City", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("pincode", "Pincode", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("gender", "Gender", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("telephone", "Telephone", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("mobileNoSms", "Mobile No (SMS)", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("aadharNo", "Aadhar No", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("panCardNo", "PAN Card No", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("ckycId", "CKYC ID", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("operation", "Operation", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("birthDate", "Birth Date", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("occupation", "Occupation", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("risk", "Risk", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("adultMinor", "Adult / Minor", PARTY_GROUP_MINOR, "text"), //
|
||||
new PartyField("guardianClientId", "Guardian", PARTY_GROUP_MINOR, "client"), //
|
||||
new PartyField("relation", "Relation", PARTY_GROUP_MINOR, "text"), //
|
||||
new PartyField("minorDate", "Comes of Age On", PARTY_GROUP_MINOR, "text"), //
|
||||
new PartyField("minAccountOpt", "Minor Account Option", PARTY_GROUP_MINOR, "text"), //
|
||||
new PartyField("openingDate", "Opening Date", PARTY_GROUP_TERMS, "text"), //
|
||||
new PartyField("dailyDepositAmount", "Daily Deposit", PARTY_GROUP_TERMS, "money"), //
|
||||
new PartyField("days", "Days", PARTY_GROUP_TERMS, "text"), //
|
||||
new PartyField("month", "Months", PARTY_GROUP_TERMS, "text"), //
|
||||
new PartyField("depositAmount", "Deposit Amount", PARTY_GROUP_TERMS, "money"), //
|
||||
new PartyField("maturityDate", "Maturity Date", PARTY_GROUP_TERMS, "text"), //
|
||||
new PartyField("ledgerNo", "Ledger No", PARTY_GROUP_TERMS, "text"), //
|
||||
new PartyField("reopenDate", "Reopen Date", PARTY_GROUP_TERMS, "text"), //
|
||||
new PartyField("freezeCode", "Freeze Code", PARTY_GROUP_STATUS, "text"), //
|
||||
new PartyField("lien", "Lien", PARTY_GROUP_STATUS, "yesno"), //
|
||||
new PartyField("lienReason", "Lien Reason", PARTY_GROUP_STATUS, "text"), //
|
||||
new PartyField("lienReasonOther", "Lien Reason (Other)", PARTY_GROUP_STATUS, "text"), //
|
||||
new PartyField("isSmsActive", "SMS Active", PARTY_GROUP_STATUS, "yesno"), //
|
||||
new PartyField("isEmailActive", "Email Active", PARTY_GROUP_STATUS, "yesno"), //
|
||||
new PartyField("od1Flag", "OD 1", PARTY_GROUP_STATUS, "yesno"), //
|
||||
new PartyField("od2Flag", "OD 2", PARTY_GROUP_STATUS, "yesno"), //
|
||||
new PartyField("payDate", "Pay Date", PARTY_GROUP_STATUS, "text"), //
|
||||
new PartyField("interestDate", "Interest Date", PARTY_GROUP_STATUS, "text"), //
|
||||
new PartyField("spDescription", "Description", PARTY_GROUP_STATUS, "text"), //
|
||||
new PartyField("advCode", "Adv Code", PARTY_GROUP_PAYOUT, "text"), //
|
||||
new PartyField("creditToBranch", "Credit To Branch", PARTY_GROUP_PAYOUT, "text"), //
|
||||
new PartyField("creditToAccount", "Credit To A/C", PARTY_GROUP_PAYOUT, "text"), //
|
||||
new PartyField("creditToAccountType", "Credit To A/C Type", PARTY_GROUP_PAYOUT, "text"));
|
||||
|
||||
/**
|
||||
* A DDS account request laid out for the checker: the client, scheme, agent and branch by name
|
||||
* rather than by number, in the sections the screen has. Only what the request carries is shown, so
|
||||
* an edit lists just the fields it changes, and a field left empty is left out.
|
||||
*/
|
||||
private List<Map<String, Object>> partyFieldsFor(final JsonObject json) {
|
||||
final List<Map<String, Object>> fields = new ArrayList<>();
|
||||
for (final PartyField field : PARTY_FIELDS) {
|
||||
if (!json.has(field.parameter()) || json.get(field.parameter()).isJsonNull()) {
|
||||
continue;
|
||||
}
|
||||
final String raw = json.get(field.parameter()).getAsString();
|
||||
if (raw.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
final String value = switch (field.kind()) {
|
||||
case "client" -> clientLabel(raw);
|
||||
case "scheme" -> nameOr(first("select account_type_code || ' · ' || account_type_description from m_dds_account_type "
|
||||
+ "where account_type_code = ?", raw), raw);
|
||||
case "agent" -> nameOr(first("select agent_code || ' · ' || coalesce(agent_name, '') from m_dds_agent where id = ?",
|
||||
parseLong(raw)), "Agent #" + raw);
|
||||
case "office" -> nameOr(first("select name from m_office where id = ?", parseLong(raw)), "Branch #" + raw);
|
||||
case "yesno" -> Boolean.parseBoolean(raw.trim()) ? "Yes" : "No";
|
||||
case "money" -> "₹" + plain(raw);
|
||||
default -> raw.trim();
|
||||
};
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("key", field.parameter());
|
||||
out.put("label", field.label());
|
||||
out.put("group", field.group());
|
||||
out.put("format", "yesno".equals(field.kind()) ? "yesno" : "text");
|
||||
out.put("value", value);
|
||||
fields.add(out);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** The nominees a DDS account request names, with what each is to receive. */
|
||||
private List<Map<String, Object>> nomineesOf(final JsonObject json) {
|
||||
final List<Map<String, Object>> nominees = new ArrayList<>();
|
||||
if (!json.has("nominees") || !json.get("nominees").isJsonArray()) {
|
||||
return nominees;
|
||||
}
|
||||
for (final JsonElement element : json.getAsJsonArray("nominees")) {
|
||||
if (!element.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
final JsonObject n = element.getAsJsonObject();
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("name", text(n, "name"));
|
||||
out.put("relation", text(n, "relation"));
|
||||
out.put("gender", text(n, "gender"));
|
||||
out.put("mobileNo", text(n, "mobileNo"));
|
||||
out.put("birthDate", text(n, "birthDate"));
|
||||
out.put("nominationDate", text(n, "nominationDate"));
|
||||
out.put("guardianName", text(n, "guardianName"));
|
||||
out.put("share", text(n, "sharePercent") != null ? plain(text(n, "sharePercent")) + "%"
|
||||
: text(n, "sequenceNo") != null ? "Order " + text(n, "sequenceNo") : null);
|
||||
nominees.add(out);
|
||||
}
|
||||
return nominees;
|
||||
}
|
||||
|
||||
/** An agent being opened or changed - the entries whose review shows the agent and their guarantors. */
|
||||
private static boolean isAgentRequest(final Map<String, Object> row) {
|
||||
return "AGENT".equals(row.get("entity")) && ("CREATE".equals(row.get("action")) || "UPDATE".equals(row.get("action")));
|
||||
}
|
||||
|
||||
/** What an agent request carries, in the order the Agent Master Entry screen asks for it. */
|
||||
private record AgentField(String parameter, String label, String group, String kind) {}
|
||||
|
||||
private static final String AGENT_GROUP = "Agent";
|
||||
private static final String AGENT_ADDRESS_GROUP = "Address & Contact";
|
||||
private static final String AGENT_ACCOUNTS_GROUP = "Branch & Accounts";
|
||||
private static final String AGENT_BALANCES_GROUP = "Balances";
|
||||
|
||||
private static final List<AgentField> AGENT_FIELDS = List.of( //
|
||||
new AgentField("clientId", "Client", AGENT_GROUP, "client"), //
|
||||
new AgentField("agentNameId", "Agent Name", AGENT_GROUP, "text"), //
|
||||
new AgentField("agentBirthDate", "Birth Date", AGENT_GROUP, "text"), //
|
||||
new AgentField("agentOpeningDate", "Opening Date", AGENT_GROUP, "text"), //
|
||||
new AgentField("agentAddress1", "Address (Line 1)", AGENT_ADDRESS_GROUP, "text"), //
|
||||
new AgentField("agentAddress2", "Address (Line 2)", AGENT_ADDRESS_GROUP, "text"), //
|
||||
new AgentField("agentCity", "City", AGENT_ADDRESS_GROUP, "text"), //
|
||||
new AgentField("agentPinCode", "PinCode", AGENT_ADDRESS_GROUP, "text"), //
|
||||
new AgentField("agentTelephone", "Telephone", AGENT_ADDRESS_GROUP, "text"), //
|
||||
new AgentField("officeId", "Home Branch", AGENT_ACCOUNTS_GROUP, "office"), //
|
||||
new AgentField("glAccountId", "GL Account", AGENT_ACCOUNTS_GROUP, "gl"), //
|
||||
new AgentField("agentSavingAccountType", "Saving A/C Type", AGENT_ACCOUNTS_GROUP, "text"), //
|
||||
new AgentField("agentSavingAccountNo", "Saving A/C No", AGENT_ACCOUNTS_GROUP, "text"), //
|
||||
new AgentField("agentOpeningBalance", "Opening Balance", AGENT_BALANCES_GROUP, "money"));
|
||||
|
||||
/**
|
||||
* An agent request laid out for the checker: the client, the branch and the ledger account by name
|
||||
* rather than by number, in the sections the screen has. Only what the request carries is shown, so
|
||||
* an edit lists just the fields it changes.
|
||||
*/
|
||||
private List<Map<String, Object>> agentFieldsFor(final JsonObject json) {
|
||||
final List<Map<String, Object>> fields = new ArrayList<>();
|
||||
for (final AgentField field : AGENT_FIELDS) {
|
||||
if (!json.has(field.parameter()) || json.get(field.parameter()).isJsonNull()) {
|
||||
continue;
|
||||
}
|
||||
final String raw = json.get(field.parameter()).getAsString();
|
||||
// A new agent starts with nothing, so a zero opening balance is not something to approve and
|
||||
// is left out. One that is not zero - an edit that sets it - is shown.
|
||||
if ("money".equals(field.kind()) && isZero(raw)) {
|
||||
continue;
|
||||
}
|
||||
final String value = switch (field.kind()) {
|
||||
case "client" -> clientLabel(raw);
|
||||
case "office" -> nameOr(first("select name from m_office where id = ?", parseLong(raw)), "Branch #" + raw);
|
||||
case "gl" -> nameOr(first("select gl_code || ' · ' || name from acc_gl_account where id = ?", parseLong(raw)),
|
||||
"Account #" + raw);
|
||||
case "money" -> "₹" + plain(raw);
|
||||
default -> raw.trim();
|
||||
};
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("key", field.parameter());
|
||||
out.put("label", field.label());
|
||||
out.put("group", field.group());
|
||||
out.put("format", "text");
|
||||
out.put("value", value);
|
||||
fields.add(out);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** The guarantors an agent request names, each with the client they are by number and name. */
|
||||
private List<Map<String, Object>> guarantorsOf(final JsonObject json) {
|
||||
final List<Map<String, Object>> guarantors = new ArrayList<>();
|
||||
if (!json.has("guarantors") || !json.get("guarantors").isJsonArray()) {
|
||||
return guarantors;
|
||||
}
|
||||
for (final JsonElement element : json.getAsJsonArray("guarantors")) {
|
||||
if (!element.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
final JsonObject g = element.getAsJsonObject();
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("client", text(g, "clientId") == null ? null : clientLabel(text(g, "clientId")));
|
||||
out.put("name", text(g, "guarantorName"));
|
||||
out.put("telephone", text(g, "telephone"));
|
||||
out.put("address", joined(", ", text(g, "address1"), text(g, "address2"), text(g, "city"), text(g, "pinCode")));
|
||||
guarantors.add(out);
|
||||
}
|
||||
return guarantors;
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent's code, name, client, branch, savings account and guarantor count. An agent already on
|
||||
* the books is read as they stand; one still being opened does not exist yet, so is read from the
|
||||
* request and has no code until approval gives it one.
|
||||
*/
|
||||
private Map<String, Object> agentColumns(final Long agentId, final JsonObject json) {
|
||||
final Map<String, Object> columns = new LinkedHashMap<>();
|
||||
if (agentId != null) {
|
||||
columns.putAll(this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select a.agent_code as \"agentCode\", a.agent_name as \"agentName\", c.display_name as \"clientName\", "
|
||||
+ "o.name as \"officeName\", a.agent_saving_account_no as \"savingAccountNo\", "
|
||||
+ "(select count(*) from m_dds_agent_guarantor g where g.agent_id = a.id) as \"guarantorCount\" "
|
||||
+ "from m_dds_agent a left join m_client c on c.id = a.client_id left join m_office o on o.id = a.office_id "
|
||||
+ "where a.id = ?",
|
||||
agentId).stream().findFirst().orElse(Map.of()));
|
||||
return columns;
|
||||
}
|
||||
final String clientId = text(json, "clientId");
|
||||
columns.put("agentCode", null);
|
||||
columns.put("agentName", text(json, "agentNameId"));
|
||||
columns.put("clientName", clientId == null ? null : first("select display_name from m_client where id = ?", parseLong(clientId)));
|
||||
final String officeId = text(json, "officeId");
|
||||
columns.put("officeName", officeId == null ? null : first("select name from m_office where id = ?", parseLong(officeId)));
|
||||
columns.put("savingAccountNo", text(json, "agentSavingAccountNo"));
|
||||
columns.put("guarantorCount", json.has("guarantors") && json.get("guarantors").isJsonArray() ? json.getAsJsonArray("guarantors").size() : 0);
|
||||
return columns;
|
||||
}
|
||||
|
||||
private String clientLabel(final String clientId) {
|
||||
final String name = first("select display_name from m_client where id = ?", parseLong(clientId));
|
||||
return name == null ? "Client #" + clientId : clientId + " · " + name;
|
||||
}
|
||||
|
||||
/** The name an agent request gives, or the client it is for when none was typed. */
|
||||
private String agentNameOf(final JsonObject json) {
|
||||
final String name = text(json, "agentNameId");
|
||||
if (name != null && !name.isBlank()) {
|
||||
return name.trim();
|
||||
}
|
||||
final String clientId = text(json, "clientId");
|
||||
return clientId == null ? "" : clientLabel(clientId);
|
||||
}
|
||||
|
||||
private static boolean isZero(final String amount) {
|
||||
try {
|
||||
return new BigDecimal(amount.trim()).signum() == 0;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static String nameOr(final String found, final String fallback) {
|
||||
return found == null ? fallback : found;
|
||||
}
|
||||
|
||||
private static String joined(final String separator, final String... parts) {
|
||||
final List<String> present = new ArrayList<>();
|
||||
for (final String part : parts) {
|
||||
if (part != null && !part.isBlank()) {
|
||||
present.add(part.trim());
|
||||
}
|
||||
}
|
||||
return String.join(separator, present);
|
||||
}
|
||||
|
||||
/** A number an agent request carried as text, or -1 - which no row has - when it was not one. */
|
||||
private static Long parseLong(final String value) {
|
||||
try {
|
||||
return Long.valueOf(value.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return -1L;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A DDS product's terms, in the order the Account Type screen asks for them: the column it is
|
||||
* stored in, the parameter the request carries it as, its label, how it reads, and the section of
|
||||
* the review it is shown in.
|
||||
*/
|
||||
private record ProductField(String column, String parameter, String label, String format, String group) {}
|
||||
|
||||
private static final String GROUP_PRODUCT = "Product";
|
||||
private static final String GROUP_INTEREST = "Interest";
|
||||
private static final String GROUP_CLOSURE = "Maturity & Premature Closure";
|
||||
private static final String GROUP_ACCOUNTING = "Accounting";
|
||||
|
||||
private static final List<ProductField> PRODUCT_FIELDS = List.of( //
|
||||
new ProductField("account_type_group", "accountTypeGroup", "Account Type Group", "text", GROUP_PRODUCT), //
|
||||
new ProductField("account_type_code", "accountTypeCode", "Account Type Code", "text", GROUP_PRODUCT), //
|
||||
new ProductField("account_type_description", "accountTypeDescription", "Description", "text", GROUP_PRODUCT), //
|
||||
new ProductField("is_active", "active", "Active", "yesno", GROUP_PRODUCT), //
|
||||
new ProductField("interest_rate", "interestRate", "Interest Rate", "percent", GROUP_INTEREST), //
|
||||
new ProductField("auto_interest_post", "autoInterestPost", "Auto Interest Post", "yesno", GROUP_INTEREST), //
|
||||
new ProductField("auto_interest_post_when", "autoInterestPostWhen", "Auto Interest Post When", "text", GROUP_INTEREST), //
|
||||
new ProductField("maturity_auto_close", "maturityAutoClose", "Maturity Auto Close", "yesno", GROUP_CLOSURE), //
|
||||
new ProductField("premature_lockin_months", "prematureLockinMonths", "Premature Lock-in", "months", GROUP_CLOSURE), //
|
||||
new ProductField("premature_interest_reduction", "prematureInterestReduction", "Premature Interest Reduction", "percent",
|
||||
GROUP_CLOSURE), //
|
||||
new ProductField("premature_agent_commission_penalty", "prematureAgentCommissionPenalty", "Agent Commission Penalty",
|
||||
"percent", GROUP_CLOSURE), //
|
||||
new ProductField("deposit_liability_gl_id", "depositLiabilityGlId", "DDS Customer Deposits (Liability)", "gl",
|
||||
GROUP_ACCOUNTING), //
|
||||
new ProductField("interest_expense_gl_id", "interestExpenseGlId", "Interest Expense", "gl", GROUP_ACCOUNTING), //
|
||||
new ProductField("commission_expense_gl_id", "commissionExpenseGlId", "Agent Commission Expense", "gl", GROUP_ACCOUNTING));
|
||||
|
||||
/**
|
||||
* What an entry that moves no money actually asks for, field by field, so the checker can read the
|
||||
* terms they are approving rather than only a code.
|
||||
*
|
||||
* An edit still waiting is shown against the product as it stands, with each changed field marked,
|
||||
* because what matters there is what will change. Once actioned, the product has already moved on,
|
||||
* so only the values the request carried are shown.
|
||||
*/
|
||||
private List<Map<String, Object>> fieldsFor(final Map<String, Object> row, final Map<String, Object> entry) {
|
||||
if (isAgentRequest(row)) {
|
||||
return agentFieldsFor(jsonOf(entry.get("json")));
|
||||
}
|
||||
if (isPartyRequest(row)) {
|
||||
return partyFieldsFor(jsonOf(entry.get("json")));
|
||||
}
|
||||
if (!"DDSACCOUNTTYPE".equals(row.get("entity"))) {
|
||||
return List.of();
|
||||
}
|
||||
final JsonObject json = jsonOf(entry.get("json"));
|
||||
final Long resourceId = (Long) row.get("resourceId");
|
||||
final boolean compare = "UPDATE".equals(row.get("action")) && "PENDING".equals(row.get("status")) && resourceId != null;
|
||||
final Map<String, Object> current = compare
|
||||
? this.jdbcTemplate.queryForList("select * from m_dds_account_type where id = ?", resourceId).stream().findFirst() // NOSONAR
|
||||
.orElse(Map.of())
|
||||
: Map.of();
|
||||
|
||||
final List<Map<String, Object>> fields = new ArrayList<>();
|
||||
for (final ProductField field : PRODUCT_FIELDS) {
|
||||
final boolean requested = json.has(field.parameter()) && !json.get(field.parameter()).isJsonNull();
|
||||
final String proposed = requested ? display(json.get(field.parameter()).getAsString(), field.format()) : null;
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("key", field.parameter());
|
||||
out.put("label", field.label());
|
||||
out.put("group", field.group());
|
||||
out.put("format", field.format());
|
||||
if (compare) {
|
||||
final Object stored = current.get(field.column());
|
||||
final String was = stored == null ? null : display(String.valueOf(stored), field.format());
|
||||
final String now = requested ? proposed : was;
|
||||
out.put("current", was);
|
||||
out.put("value", now);
|
||||
out.put("changed", requested && !Objects.equals(was, now));
|
||||
} else if (requested) {
|
||||
out.put("value", proposed);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
fields.add(out);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** As format, and a ledger account id read as the account itself: "DDS CUSTOMER DEPOSITS (200601)". */
|
||||
private String display(final String value, final String format) {
|
||||
if (!"gl".equals(format) || value == null || value.isBlank()) {
|
||||
return format(value, format);
|
||||
}
|
||||
final Map<String, Object> gl = this.jdbcTemplate.queryForList("select gl_code, name from acc_gl_account where id = ?", // NOSONAR
|
||||
parseLong(value)).stream().findFirst().orElse(Map.of());
|
||||
return gl.isEmpty() ? "GL " + value.trim() + " (not found)" : gl.get("name") + " (" + gl.get("gl_code") + ")";
|
||||
}
|
||||
|
||||
/** A stored or requested value as the screen shows it: Yes/No, 2%, 12 months. */
|
||||
private static String format(final String value, final String format) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return switch (format) {
|
||||
case "yesno" -> Boolean.parseBoolean(value.trim()) ? "Yes" : "No";
|
||||
case "percent" -> plain(value) + "%";
|
||||
case "months" -> plain(value) + " month" + ("1".equals(plain(value)) ? "" : "s");
|
||||
default -> value.trim();
|
||||
};
|
||||
}
|
||||
|
||||
/** 2.000000 reads as 2, and 1.50 as 1.5, so a stored rate and a typed one compare equal. */
|
||||
private static String plain(final String value) {
|
||||
try {
|
||||
return new BigDecimal(value.trim()).stripTrailingZeros().toPlainString();
|
||||
} catch (NumberFormatException e) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The debit and credit sides of the entry, as its voucher shows them. Once the money has moved they
|
||||
* are the voucher's own lines; while it waits they are what approving it will move, worked out from
|
||||
@@ -160,13 +578,40 @@ public class DdsApprovalReadService {
|
||||
}
|
||||
}
|
||||
case "DDSBATCH:POST" -> {
|
||||
final String expense = first("select c.name from m_dds_batch_run r join c_configuration c on c.name = "
|
||||
+ "case when r.job_type like 'COMMISSION%' then 'dds-agent-commission-expense-account' "
|
||||
+ "else 'dds-interest-expense-account' end where r.id = ?", resourceId);
|
||||
final Map<String, Object> gl = expense == null ? Map.of()
|
||||
: this.jdbcTemplate.queryForList("select g.gl_code as \"code\", g.name from c_configuration c " // NOSONAR
|
||||
+ "join acc_gl_account g on g.id = c.value where c.name = ?", expense).stream().findFirst().orElse(Map.of());
|
||||
lines.add(line("DR", (String) gl.get("code"), (String) gl.get("name"), "GL", amount));
|
||||
// The expense side, on the accounts of the DDS products concerned - interest on each
|
||||
// customer's product's interest account, commission shared over the commission accounts
|
||||
// of the products each agent collected for, as the posting itself will book it.
|
||||
final String jobType = first("select job_type from m_dds_batch_run where id = ?", resourceId);
|
||||
if (jobType != null && jobType.startsWith("COMMISSION")) {
|
||||
final Map<String, BigDecimal> byAccount = new LinkedHashMap<>();
|
||||
final Map<String, String> names = new LinkedHashMap<>();
|
||||
for (final Map<String, Object> runRow : this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select agent_id, amount from m_dds_batch_run_row where batch_run_id = ? and amount > 0 order by id", resourceId)) {
|
||||
try {
|
||||
this.ddsLedger.commissionSplit(((Number) runRow.get("agent_id")).longValue(), (BigDecimal) runRow.get("amount"), resourceId)
|
||||
.forEach((account, share) -> {
|
||||
byAccount.merge(account.getGlCode(), share, BigDecimal::add);
|
||||
names.put(account.getGlCode(), account.getName());
|
||||
});
|
||||
} catch (final DdsLedgerNotConfiguredException e) {
|
||||
byAccount.merge("", (BigDecimal) runRow.get("amount"), BigDecimal::add);
|
||||
names.put("", "Agent Commission Expense not set on the DDS product");
|
||||
}
|
||||
}
|
||||
byAccount.forEach((code, share) -> lines.add(line("DR", code.isEmpty() ? null : code, names.get(code), "GL", share)));
|
||||
} else {
|
||||
for (final Map<String, Object> gl : this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select g.gl_code as \"code\", coalesce(g.name, 'Interest Expense not set on DDS product ' || "
|
||||
+ "upper(trim(p.sub_type))) as \"name\", sum(r.amount) as \"amount\" from m_dds_batch_run_row r "
|
||||
+ "join m_dds_party p on p.id = r.party_id "
|
||||
+ "left join m_dds_account_type t on t.account_type_code = upper(trim(p.sub_type)) "
|
||||
+ "left join acc_gl_account g on g.id = t.interest_expense_gl_id "
|
||||
+ "where r.batch_run_id = ? and r.amount > 0 group by g.gl_code, g.name, upper(trim(p.sub_type)) "
|
||||
+ "order by g.gl_code",
|
||||
resourceId)) {
|
||||
lines.add(line("DR", (String) gl.get("code"), (String) gl.get("name"), "GL", (BigDecimal) gl.get("amount")));
|
||||
}
|
||||
}
|
||||
for (final Map<String, Object> credited : this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select coalesce(p.account_no, a.agent_saving_account_no) as \"accountNo\", "
|
||||
+ "coalesce(p.party_code || ' · ' || p.party_name, a.agent_code || ' · ' || a.agent_name) as \"accountName\", "
|
||||
@@ -314,11 +759,15 @@ public class DdsApprovalReadService {
|
||||
case "CLOSE" -> "Close Agent";
|
||||
default -> "Edit Agent";
|
||||
};
|
||||
details = resourceId != null ? agent(resourceId) : String.valueOf(text(json, "agentName"));
|
||||
details = resourceId != null ? agent(resourceId) : agentNameOf(json);
|
||||
}
|
||||
case "DDSACCOUNTTYPE:CREATE", "DDSACCOUNTTYPE:UPDATE" -> {
|
||||
type = "CREATE".equals(action) ? "New DDS Product" : "Edit DDS Product";
|
||||
details = text(json, "accountTypeCode") != null ? text(json, "accountTypeCode") : scheme(resourceId);
|
||||
final String description = text(json, "accountTypeDescription");
|
||||
if (description != null && !description.isBlank()) {
|
||||
details = details == null || details.isBlank() ? description : details + " · " + description;
|
||||
}
|
||||
}
|
||||
case "COMMISSIONSLAB:CREATE", "COMMISSIONSLAB:UPDATE" -> {
|
||||
type = "CREATE".equals(action) ? "New Commission Slab" : "Edit Commission Slab";
|
||||
@@ -342,6 +791,14 @@ public class DdsApprovalReadService {
|
||||
row.put("type", type);
|
||||
row.put("details", details);
|
||||
row.put("amount", amount);
|
||||
if ("PARTY".equals(entity) && ("CREATE".equals(action) || "UPDATE".equals(action))) {
|
||||
// The account in the columns Approvals -> Pending Account lists every other account by.
|
||||
row.putAll(accountColumns(resourceId, json));
|
||||
}
|
||||
if ("AGENT".equals(entity)) {
|
||||
// The agent in the columns Approvals -> Agent Approval lists them by.
|
||||
row.putAll(agentColumns(resourceId, json));
|
||||
}
|
||||
final Long commandId = ((Number) entry.get("id")).longValue();
|
||||
final String voucherNo = switch (status) {
|
||||
case STATUS_PROCESSED -> voucherFor(commandId, entity, action, resourceId, json, (String) entry.get("madeOn"));
|
||||
@@ -428,6 +885,37 @@ public class DdsApprovalReadService {
|
||||
return found == null ? "Agent #" + agentId : found.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* A DDS account's number, holder, scheme, branch and agent. An edit reads the account as it
|
||||
* stands; a new account does not exist until approved, so it is read from the request, and has
|
||||
* no number yet.
|
||||
*/
|
||||
private Map<String, Object> accountColumns(final Long partyId, final JsonObject json) {
|
||||
final Map<String, Object> columns = new LinkedHashMap<>();
|
||||
if (partyId != null) {
|
||||
final Map<String, Object> stored = this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select p.account_no as \"accountNo\", p.party_name as \"clientName\", p.sub_type as \"subType\", "
|
||||
+ "o.name as \"officeName\", trim(a.agent_code || ' ' || coalesce(a.agent_name, '')) as \"agentName\" "
|
||||
+ "from m_dds_party p left join m_office o on o.id = p.office_id "
|
||||
+ "left join m_dds_agent a on a.id = p.agent_id where p.id = ?",
|
||||
partyId).stream().findFirst().orElse(Map.of());
|
||||
columns.putAll(stored);
|
||||
} else {
|
||||
columns.put("accountNo", null);
|
||||
columns.put("clientName", text(json, "partyName"));
|
||||
columns.put("subType", text(json, "subType"));
|
||||
final Long officeId = longOf(json, "officeId");
|
||||
columns.put("officeName", officeId == null ? null : first("select name from m_office where id = ?", officeId));
|
||||
final Long agentId = longOf(json, "agentId");
|
||||
columns.put("agentName", agentId == null ? null : agent(agentId));
|
||||
}
|
||||
final String subType = (String) columns.remove("subType");
|
||||
final String description = subType == null ? null
|
||||
: first("select account_type_description from m_dds_account_type where account_type_code = ?", subType);
|
||||
columns.put("productName", description == null ? subType : subType + " · " + description);
|
||||
return columns;
|
||||
}
|
||||
|
||||
private String scheme(final Long accountTypeId) {
|
||||
return accountTypeId == null ? "" : first("select account_type_code from m_dds_account_type where id = ?", accountTypeId);
|
||||
}
|
||||
|
||||
+17
@@ -22,6 +22,7 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
@@ -38,6 +39,8 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.dds.batch.data.BatchRunData;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunReadPlatformService;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/dds/batchruns")
|
||||
@@ -52,6 +55,20 @@ public class BatchRunsApiResource {
|
||||
private final BatchRunReadPlatformService readPlatformService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final DdsVoucherService ddsVoucherService;
|
||||
private final BatchRunWritePlatformService writePlatformService;
|
||||
|
||||
@DELETE
|
||||
@Path("{batchRunId}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Discard a previewed DDS Batch Run", description = "Deletes a run that was generated but never posted - "
|
||||
+ "the preview the screen's Discard button throws away. Refused for a run that is posted, posting, failed, or whose "
|
||||
+ "posting is waiting for approval.")
|
||||
public CommandProcessingResult discard(@PathParam("batchRunId") @Parameter(description = "batchRunId") final Long batchRunId) {
|
||||
// Whoever may generate a preview may throw one away; it moved nothing.
|
||||
this.context.authenticatedUser().validateHasPermissionTo("GENERATE_" + RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
this.writePlatformService.discardBatchRun(batchRunId);
|
||||
return new CommandProcessingResultBuilder().withEntityId(batchRunId).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
|
||||
+6
@@ -30,6 +30,12 @@ public interface BatchRunWritePlatformService {
|
||||
|
||||
CommandProcessingResult postBatchRun(Long batchRunId, JsonCommand command);
|
||||
|
||||
/**
|
||||
* Throws away a run that was only previewed - generated and never posted, nor waiting for a
|
||||
* checker to approve its posting. Nothing was applied by generating it, so nothing is undone.
|
||||
*/
|
||||
void discardBatchRun(Long batchRunId);
|
||||
|
||||
/**
|
||||
* Posts interest for every scheme whose Account Type says to do it automatically and whose posting
|
||||
* day falls on the given date. Driven by the Post DDS Interest scheduled job.
|
||||
|
||||
+100
-34
@@ -29,6 +29,8 @@ import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||
@@ -83,6 +85,7 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
* re-running it for the same period finds no days left; a posting takes its amount off the parked
|
||||
* figure, so re-posting finds nothing left to move.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWritePlatformService {
|
||||
|
||||
@@ -141,9 +144,13 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
private static final String COMMISSION_ACCOUNT_SQL = "select a.id, a.status_enum, a.product_id, p.deposit_type_enum "
|
||||
+ "from m_savings_account a join m_savings_product p on p.id = a.product_id where a.account_no = ?";
|
||||
|
||||
/** The product DDS opens customers' scheme accounts under - see 0492. */
|
||||
private static final String DDS_DEPOSIT_PRODUCT_SQL = "select value from c_configuration "
|
||||
+ "where name = 'dds-deposit-product' and enabled = true";
|
||||
/**
|
||||
* Whether a savings account is a DDS customer's scheme account - one a DDS party is linked to.
|
||||
* Read off the parties themselves rather than a configured product id: what makes an account a
|
||||
* scheme account is that a scheme runs on it, whatever product it was opened under.
|
||||
*/
|
||||
private static final String DDS_SCHEME_ACCOUNT_SQL = "select count(*) from m_dds_party p "
|
||||
+ "join m_savings_account a on a.id = p.savings_account_id where a.account_no = ?";
|
||||
|
||||
private static final int SAVINGS_DEPOSIT_TYPE = 100;
|
||||
private static final int FIXED_DEPOSIT_TYPE = 200;
|
||||
@@ -189,6 +196,9 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType);
|
||||
}
|
||||
|
||||
// A new preview of the same job replaces the one before it: generating twice used to leave two
|
||||
// identical "not posted" runs on the list, and nobody could tell which one to post.
|
||||
discardSupersededPreviews(jobType, subType);
|
||||
this.batchRunRepository.saveAndFlush(batchRun);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
@@ -197,6 +207,35 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* A run that is only a preview: calculated, never posted, not posting or failed, and with no
|
||||
* posting of it waiting for a checker (a held posting names the run, and approving it later must
|
||||
* find it). Only such a run is ever deleted - generating applies nothing, so nothing is lost.
|
||||
*/
|
||||
private static final String PREVIEW_ONLY = "r.status = 'CALCULATED' and not exists (select 1 from m_portfolio_command_source c "
|
||||
+ "where c.entity_name = 'DDSBATCH' and c.resource_id = r.id and c.status = 2)";
|
||||
|
||||
private void discardSupersededPreviews(final String jobType, final String subType) {
|
||||
final String sameJob = "r.job_type = ? and coalesce(r.sub_type, '') = coalesce(?, '') and " + PREVIEW_ONLY;
|
||||
this.jdbcTemplate.update("delete from m_dds_batch_run_row where batch_run_id in (select r.id from m_dds_batch_run r where " // NOSONAR
|
||||
+ sameJob + ")", jobType, subType);
|
||||
this.jdbcTemplate.update("delete from m_dds_batch_run r where " + sameJob, jobType, subType); // NOSONAR
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public void discardBatchRun(final Long batchRunId) {
|
||||
final List<Long> found = this.jdbcTemplate.queryForList("select r.id from m_dds_batch_run r where r.id = ? and " + PREVIEW_ONLY, // NOSONAR
|
||||
Long.class, batchRunId);
|
||||
if (found.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.dds.batch.run.not.discardable",
|
||||
"Batch run #" + batchRunId + " cannot be discarded: only a run that was generated and not posted (or sent for"
|
||||
+ " posting approval) can be.");
|
||||
}
|
||||
this.jdbcTemplate.update("delete from m_dds_batch_run_row where batch_run_id = ?", batchRunId); // NOSONAR
|
||||
this.jdbcTemplate.update("delete from m_dds_batch_run where id = ?", batchRunId); // NOSONAR
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the "Branch" checklist selection - null/empty means no filter ("All"). Matched against
|
||||
* Agent.homeBranch by name, since that field is plain text (not a real Office FK) mirroring the
|
||||
@@ -596,26 +635,47 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
final String jobType = batchRun.getJobType();
|
||||
final LocalDate jobDate = batchRun.getJobDate();
|
||||
|
||||
// Only rows that actually moved money are counted, and their sum kept beside the run's total: a
|
||||
// posting row is passed over when another run has already paid what it lists, and the run must
|
||||
// not read as though it paid it again.
|
||||
int done = 0;
|
||||
BigDecimal moved = BigDecimal.ZERO;
|
||||
for (final BatchRunRow row : batchRun.getRows()) {
|
||||
switch (jobType) {
|
||||
case INTEREST_CALCULATION -> accrue(row, jobDate, false);
|
||||
case OD_INTEREST_CALCULATION -> accrue(row, jobDate, true);
|
||||
final boolean applied = switch (jobType) {
|
||||
case INTEREST_CALCULATION -> {
|
||||
accrue(row, jobDate, false);
|
||||
yield true;
|
||||
}
|
||||
case OD_INTEREST_CALCULATION -> {
|
||||
accrue(row, jobDate, true);
|
||||
yield true;
|
||||
}
|
||||
case INTEREST_POSTING -> postInterest(row, batchRun, false);
|
||||
case OD_INTEREST_POSTING -> postInterest(row, batchRun, true);
|
||||
case COMMISSION_CALCULATION -> accrueCommission(row, batchRun);
|
||||
case COMMISSION_CALCULATION -> {
|
||||
accrueCommission(row, batchRun);
|
||||
yield true;
|
||||
}
|
||||
case COMMISSION_POSTING -> payCommission(row, batchRun);
|
||||
case MATURITY_AUTO_CLOSE -> closeAtMaturity(row);
|
||||
case MATURITY_AUTO_CLOSE -> {
|
||||
closeAtMaturity(row);
|
||||
yield true;
|
||||
}
|
||||
default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType);
|
||||
};
|
||||
if (applied) {
|
||||
done++;
|
||||
moved = moved.add(row.getAmount() == null ? BigDecimal.ZERO : row.getAmount());
|
||||
} else {
|
||||
log.warn("DDS batch run #{}: row {} passed over - what it lists was already paid", batchRun.getId(), row.getId());
|
||||
}
|
||||
done++;
|
||||
}
|
||||
|
||||
// Only at the end. A crediting run is one transaction from start to finish, so a count written
|
||||
// part-way through would be invisible outside it until the run commits - and writing it from a
|
||||
// transaction of its own would have two of them contending for the same row. The status says
|
||||
// POSTING while it works, which is what a clerk actually needs to know.
|
||||
batchRun.recordProgress(done);
|
||||
batchRun.recordPosted(done, moved);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -662,10 +722,10 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
* same accrued interest would each list it in full, and paying both would credit interest that
|
||||
* was never earned.
|
||||
*/
|
||||
private void postInterest(final BatchRunRow row, final BatchRun batchRun, final boolean odInterest) {
|
||||
private boolean postInterest(final BatchRunRow row, final BatchRun batchRun, final boolean odInterest) {
|
||||
final Party party = partyOf(row.getPartyId());
|
||||
if (party.accruedInterest(odInterest).compareTo(row.getAmount()) < 0) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
final BigDecimal newBalance = party.postInterest(row.getAmount(), odInterest);
|
||||
@@ -678,6 +738,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
final String transactionType = odInterest ? "OD_INTEREST_POST" : "INTEREST_POST";
|
||||
this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), batchRun.getJobDate(), transactionType,
|
||||
DIRECTION_CREDIT, null, null, null, row.getAmount(), null, null, "DDS batch run #" + batchRun.getId(), newBalance));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -716,12 +777,12 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
* customers and not yet handed to a branch, which commission has nothing to do with. The entry is
|
||||
* recorded against the balance as it stands, so the ledger reads consistently.
|
||||
*/
|
||||
private void payCommission(final BatchRunRow row, final BatchRun batchRun) {
|
||||
private boolean payCommission(final BatchRunRow row, final BatchRun batchRun) {
|
||||
final Agent agent = agentOf(row.getAgentId());
|
||||
// Only what is actually owed can be paid: two posting runs generated over the same owed
|
||||
// figure would each list it in full, and paying both would pay commission twice.
|
||||
if (agent.getCommission().compareTo(row.getAmount()) < 0) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
agent.payOutCommission(row.getAmount());
|
||||
|
||||
@@ -730,6 +791,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
this.partyTransactionRepository.save(new PartyTransaction(null, agent, batchRun.getJobDate(), "COMMISSION_POST", DIRECTION_CREDIT,
|
||||
null, null, agent.getAgentSavingAccountNo(), row.getAmount(), null, null, "DDS batch run #" + batchRun.getId(),
|
||||
agent.getAgentCurrentBalance()));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -773,8 +835,8 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
+ " Set an ordinary savings account for this agent");
|
||||
}
|
||||
|
||||
final List<Long> ddsProduct = this.jdbcTemplate.queryForList(DDS_DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR
|
||||
if (!ddsProduct.isEmpty() && productId.equals(ddsProduct.get(0))) {
|
||||
final Integer schemes = this.jdbcTemplate.queryForObject(DDS_SCHEME_ACCOUNT_SQL, Integer.class, accountNo); // NOSONAR
|
||||
if (schemes != null && schemes > 0) {
|
||||
throw new AgentCommissionAccountUnusableException(agent.getAgentCode(), "account " + accountNo
|
||||
+ " is a DDS scheme account belonging to a customer, so commission paid into it would be counted as their"
|
||||
+ " deposits. Set an ordinary savings account for this agent");
|
||||
@@ -787,8 +849,12 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
"account " + accountNo + " is " + statusName(status) + " rather than active, so it cannot take a deposit yet");
|
||||
}
|
||||
|
||||
// Each product the agent collected for books its share on its own commission account; the
|
||||
// collections the share was worked out on are then marked as paid for.
|
||||
final Map<GLAccount, BigDecimal> shares = this.ddsLedger.commissionSplit(agent.getId(), amount, batchRun.getId());
|
||||
this.ddsLedger.payCommission(((Number) account.get("id")).longValue(), batchRun.getJobDate(), amount,
|
||||
"DDS commission, batch run #" + batchRun.getId());
|
||||
"DDS commission, batch run #" + batchRun.getId(), shares);
|
||||
this.ddsLedger.markCommissionPaid(agent.getId(), batchRun.getId());
|
||||
}
|
||||
|
||||
/** Fineract's savings statuses, named the way the screens name them. */
|
||||
@@ -810,28 +876,36 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Posts interest for every scheme that is due today.
|
||||
* Posts interest for every scheme whose posting period ended yesterday.
|
||||
*
|
||||
* A scheme that posts automatically has decided it needs no review, so this does both halves in
|
||||
* one go: it calculates the interest since the last run and then posts it into the customers'
|
||||
* balances. Both halves go through the same code the screen's Post button uses, and both leave a
|
||||
* run behind, so an automatic posting is as visible and as auditable as a manual one.
|
||||
*
|
||||
* Runs are scoped to one scheme at a time, because the posting day belongs to the scheme.
|
||||
* Interest posts at the end of the period - the last day of the month, quarter, half-year or
|
||||
* year - as it does on savings and fixed deposits, so there is no posting day to configure and
|
||||
* no month whose length can trip one up. The job runs just after midnight, so it looks back at
|
||||
* the day that has just closed: posting on the 1st through the last day of the previous month
|
||||
* takes in everything collected on that last day, which a run at 00:20 on the day itself would
|
||||
* not yet have seen.
|
||||
*
|
||||
* Runs are scoped to one scheme at a time, because the posting frequency belongs to the scheme.
|
||||
*/
|
||||
@Transactional
|
||||
@Override
|
||||
public int postDueAutoInterest(final LocalDate businessDate) {
|
||||
final LocalDate periodEnd = businessDate.minusDays(1);
|
||||
int posted = 0;
|
||||
|
||||
for (final DdsAccountType accountType : this.ddsAccountTypeRepository.findDueForAutoInterestPost()) {
|
||||
if (!isDueOn(accountType, businessDate)) {
|
||||
if (!isPeriodEnd(accountType, periodEnd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final String subType = accountType.getAccountTypeCode();
|
||||
calculateAndPost(INTEREST_CALCULATION, businessDate, subType);
|
||||
calculateAndPost(INTEREST_POSTING, businessDate, subType);
|
||||
calculateAndPost(INTEREST_CALCULATION, periodEnd, subType);
|
||||
calculateAndPost(INTEREST_POSTING, periodEnd, subType);
|
||||
posted++;
|
||||
}
|
||||
|
||||
@@ -839,19 +913,11 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this scheme's posting day falls on the given date.
|
||||
*
|
||||
* A day beyond the length of the month means the end of that month: a scheme set to 31 posts on
|
||||
* 31 January, 28 February and 30 April. Without that, February would simply be skipped and the
|
||||
* account would lose a month's interest every year.
|
||||
* Whether the given date closes one of this scheme's posting periods: the last day of a month
|
||||
* the scheme's frequency posts in. February's end is the 28th or 29th, whichever the year has.
|
||||
*/
|
||||
private boolean isDueOn(final DdsAccountType accountType, final LocalDate date) {
|
||||
if (!monthIsDue(accountType.getAutoInterestPostWhen(), date)) {
|
||||
return false;
|
||||
}
|
||||
final int configuredDay = accountType.getAutoInterestPostDay();
|
||||
final int effectiveDay = Math.min(configuredDay, date.lengthOfMonth());
|
||||
return date.getDayOfMonth() == effectiveDay;
|
||||
private boolean isPeriodEnd(final DdsAccountType accountType, final LocalDate date) {
|
||||
return monthIsDue(accountType.getAutoInterestPostWhen(), date) && date.getDayOfMonth() == date.lengthOfMonth();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+11
@@ -115,6 +115,7 @@ public final class CollectionEntryCommandFromApiJsonDeserializer {
|
||||
.inMinMaxRange(1, DdsSchedule.CATCH_UP_DAYS);
|
||||
} else {
|
||||
v.reset().parameter(ENTRIES).parameterAtIndexArray("amount", i).value(decimalOf(row)).notNull().positiveAmount();
|
||||
toThePaisa(v, decimalOf(row));
|
||||
}
|
||||
|
||||
final String mode = stringOf(row, "transactionMode");
|
||||
@@ -201,4 +202,14 @@ public final class CollectionEntryCommandFromApiJsonDeserializer {
|
||||
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Money is kept to the paisa, as a savings account keeps it: 100.555 is refused rather than stored
|
||||
* on the passbook and the journal as typed.
|
||||
*/
|
||||
private static void toThePaisa(final DataValidatorBuilder v, final BigDecimal amount) {
|
||||
if (amount != null && amount.stripTrailingZeros().scale() > 2) {
|
||||
v.failWithCodeAndMessage("more.than.two.decimals", "An amount can have at most two decimal places (paise).", amount);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+33
-10
@@ -49,6 +49,7 @@ import org.apache.fineract.infrastructure.security.service.PlatformSecurityConte
|
||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
||||
import org.apache.fineract.organisation.agent.domain.AgentRepository;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
|
||||
import org.apache.fineract.organisation.office.domain.Office;
|
||||
import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntry;
|
||||
import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntryRepository;
|
||||
import org.apache.fineract.portfolio.dds.collection.exception.CollectionEntryNotFoundException;
|
||||
@@ -336,32 +337,54 @@ public class CollectionEntryWritePlatformServiceJpaRepositoryImpl implements Col
|
||||
}
|
||||
final String description = "DDS collection by " + agent.getAgentCode() + ", voucher " + String.join(", ", voucherNos);
|
||||
|
||||
BigDecimal pooled = BigDecimal.ZERO;
|
||||
// What the customers on DDS's own books are now owed, by the customer-deposits account of their
|
||||
// product and the branch they bank at - two products owe on two accounts, and a branch's DDS
|
||||
// Customer Deposits must add up to its own customers' balances.
|
||||
final String journalId = "DDSR-A" + applicationId;
|
||||
final Map<String, BigDecimal> pooled = new LinkedHashMap<>();
|
||||
final Map<String, GLAccount> depositAccounts = new LinkedHashMap<>();
|
||||
final Map<String, Office> offices = new LinkedHashMap<>();
|
||||
for (final CollectionEntry entry : pending) {
|
||||
final Party party = collectableParty(entry.getParty().getPartyCode(), agent);
|
||||
|
||||
if (party.getSavingsAccountId() != null) {
|
||||
final boolean pooledEntry = party.getSavingsAccountId() == null;
|
||||
if (!pooledEntry) {
|
||||
this.ddsLedger.applyAgentReceiptToSavings(party, settledOn, entry.getAmount(), receiptAccount, description);
|
||||
} else {
|
||||
pooled = pooled.add(entry.getAmount());
|
||||
final GLAccount deposits = this.ddsLedger.depositLiabilityOf(party);
|
||||
final Office office = this.ddsLedger.officeOf(party);
|
||||
final String key = office.getId() + ":" + deposits.getId();
|
||||
depositAccounts.putIfAbsent(key, deposits);
|
||||
offices.putIfAbsent(key, office);
|
||||
pooled.merge(key, entry.getAmount(), BigDecimal::add);
|
||||
}
|
||||
|
||||
// The customer's own statement records the day the money was collected, as it always has.
|
||||
// The agent's cash in hand is not raised: this cash was counted in at the counter, not
|
||||
// carried.
|
||||
final BigDecimal balanceAfter = party.credit(entry.getAmount());
|
||||
final PartyTransaction transaction = this.partyTransactionRepository.saveAndFlush(new PartyTransaction(party, agent,
|
||||
entry.getEntryDate(), "PARTY_TRAN", "CREDIT", entry.getTransactionMode(), "GL", receiptAccount.getGlCode(),
|
||||
entry.getAmount(), entry.getChequeNo(), entry.getTokenNo(), description, balanceAfter));
|
||||
final PartyTransaction line = new PartyTransaction(party, agent, entry.getEntryDate(), "PARTY_TRAN", "CREDIT",
|
||||
entry.getTransactionMode(), "GL", receiptAccount.getGlCode(), entry.getAmount(), entry.getChequeNo(),
|
||||
entry.getTokenNo(), description, balanceAfter);
|
||||
// A pooled line's journal is written after the loop, so it is named here rather than taken
|
||||
// from whatever the operation wrote last.
|
||||
final PartyTransaction transaction = this.partyTransactionRepository
|
||||
.saveAndFlush(pooledEntry ? line.linkJournal(journalId) : line);
|
||||
|
||||
entry.markSettledByReceipt(settledOn, transaction.getId(), applicationId);
|
||||
this.collectionEntryRepository.save(entry);
|
||||
}
|
||||
|
||||
// Everyone on DDS's own accounts, in one pair.
|
||||
final String journalId = "DDSR-A" + applicationId;
|
||||
if (pooled.signum() > 0) {
|
||||
this.ddsLedger.applyAgentReceipts(agent.getOffice(), settledOn, pooled, receiptAccount, journalId, description);
|
||||
// Everyone on DDS's own accounts, in one pair per branch and customer-deposits account, all under one journal id.
|
||||
boolean journalled = false;
|
||||
for (final Map.Entry<String, BigDecimal> owed : pooled.entrySet()) {
|
||||
if (owed.getValue().signum() > 0) {
|
||||
this.ddsLedger.applyAgentReceipts(offices.get(owed.getKey()), settledOn, owed.getValue(), receiptAccount,
|
||||
depositAccounts.get(owed.getKey()), journalId, description);
|
||||
journalled = true;
|
||||
}
|
||||
}
|
||||
if (journalled) {
|
||||
this.jdbcTemplate.update(SET_JOURNAL_SQL, journalId, applicationId); // NOSONAR
|
||||
}
|
||||
|
||||
|
||||
+17
@@ -110,6 +110,23 @@ public class PartiesApiResource {
|
||||
return readPlatformService.retrieveCreditableAccounts(clientId);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("nextcode")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "What the Party Master Entry screen shows before saving",
|
||||
description = "partyCode: the code the next DDS party will be given - worked out the way saving does it (PT and one "
|
||||
+ "more than the highest number on the books), but only an estimate: another party saved first, or one "
|
||||
+ "waiting for approval, can take it. lastAccountNo: the number of the DDS account opened most recently, "
|
||||
+ "null when there is none. Both are for display; nothing is saved from them.")
|
||||
public Map<String, Object> retrieveNextPartyCode() {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
final Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||
result.put("partyCode", readPlatformService.nextPartyCode());
|
||||
result.put("lastAccountNo", readPlatformService.lastAccountNo());
|
||||
return result;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("sundryaccounts")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
|
||||
+13
@@ -35,6 +35,19 @@ public interface PartyReadPlatformService {
|
||||
*/
|
||||
Collection<Map<String, Object>> retrieveCreditableAccounts(Long clientId);
|
||||
|
||||
/**
|
||||
* The code the next party saved will be given, for a screen to show before it is saved. It is worked
|
||||
* out the way saving does it - "PT" and one more than the highest number on the books - but only an
|
||||
* estimate: another party saved first, or one waiting for approval, can take it.
|
||||
*/
|
||||
String nextPartyCode();
|
||||
|
||||
/**
|
||||
* The account number of the DDS account opened most recently, or null when none has been - what the
|
||||
* Party Master Entry screen shows as Last A/c. Read only: nothing is saved from it.
|
||||
*/
|
||||
String lastAccountNo();
|
||||
|
||||
/**
|
||||
* How a closed account was closed - early or at maturity, by whom, what the balance came to and
|
||||
* where it went - gathered from its transactions, the ledger and the command that closed it.
|
||||
|
||||
+29
-3
@@ -104,7 +104,10 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
|
||||
+ " p.freeze_code as freezeCode, p.adv_code as advCode, p.credit_to_branch as creditToBranch, "
|
||||
+ " p.credit_to_account_type as creditToAccountType, p.credit_to_account as creditToAccount, "
|
||||
+ " p.nominee_applicable as nomineeApplicable, p.nominee_split_mode as nomineeSplitMode, "
|
||||
+ " p.status as status, p.close_date as closeDate "
|
||||
+ " p.status as status, p.close_date as closeDate, p.account_type_id as accountTypeId, "
|
||||
+ " p.currency_code as currencyCode, p.activatedon_date as activatedOnDate, "
|
||||
+ " (select u.username from m_appuser u where u.id = p.activatedon_userid) as activatedByUsername, "
|
||||
+ " (select u.username from m_appuser u where u.id = p.closedon_userid) as closedByUsername "
|
||||
// Left join: sub_type is a plain code column rather than a foreign key, so a party
|
||||
// whose account type was renamed away or predates the master still lists, just
|
||||
// without a description.
|
||||
@@ -214,6 +217,11 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
|
||||
.nomineeSplitMode(rs.getString("nomineeSplitMode")) //
|
||||
.status(rs.getString("status")) //
|
||||
.closeDate(JdbcSupport.getLocalDate(rs, "closeDate")) //
|
||||
.accountTypeId(JdbcSupport.getLong(rs, "accountTypeId")) //
|
||||
.currencyCode(rs.getString("currencyCode")) //
|
||||
.activatedOnDate(JdbcSupport.getLocalDate(rs, "activatedOnDate")) //
|
||||
.activatedByUsername(rs.getString("activatedByUsername")) //
|
||||
.closedByUsername(rs.getString("closedByUsername")) //
|
||||
.nominees(new ArrayList<>()) //
|
||||
.build();
|
||||
}
|
||||
@@ -326,14 +334,32 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
|
||||
+ "p.name as \"productName\", a.account_balance_derived as balance, 'SAVINGS' as \"accountKind\" from m_savings_account a "
|
||||
+ "join m_savings_product p on p.id = a.product_id "
|
||||
+ "where a.client_id = ? and a.status_enum = 300 and p.deposit_type_enum = 100 "
|
||||
+ "and a.product_id is distinct from (select cast(value as bigint) from c_configuration "
|
||||
+ "where name = 'dds-deposit-product' and enabled = true) "
|
||||
+ "and not exists (select 1 from m_dds_party dp where dp.savings_account_id = a.id) "
|
||||
+ "union all "
|
||||
+ "select c.id as id, c.account_no as \"accountNo\", cp.name as \"productName\", c.account_balance_derived as balance, "
|
||||
+ "'CURRENT' as \"accountKind\" from m_current_account c join m_current_product cp on cp.id = c.product_id "
|
||||
+ "where c.client_id = ? and c.status_enum = 300"
|
||||
+ ") accounts order by \"accountNo\"";
|
||||
|
||||
@Override
|
||||
public String nextPartyCode() {
|
||||
// The same figure saving a party works from - the highest number among every code on the books,
|
||||
// closed parties included - so what a screen shows is what saving would give it right now.
|
||||
final Long highest = this.jdbcTemplate.queryForObject( // NOSONAR
|
||||
"select coalesce(max(cast(substring(party_code, 3) as bigint)), 0) from m_dds_party where party_code like 'PT%'",
|
||||
Long.class);
|
||||
return String.format("PT%04d", (highest == null ? 0L : highest) + 1L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String lastAccountNo() {
|
||||
// The highest number on the DDS account serial - the account opened last. Closed accounts count:
|
||||
// this says what was opened, not what is still open.
|
||||
final List<String> found = this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select account_no from m_dds_party where account_no is not null order by account_no desc limit 1", String.class);
|
||||
return found.isEmpty() ? null : found.get(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Map<String, Object>> retrieveCreditableAccounts(final Long clientId) {
|
||||
return this.jdbcTemplate.queryForList(CREDITABLE_ACCOUNTS_SQL, clientId, clientId); // NOSONAR
|
||||
|
||||
+114
-34
@@ -29,6 +29,7 @@ import java.time.LocalDate;
|
||||
import java.time.Period;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
@@ -79,10 +80,10 @@ import org.apache.fineract.portfolio.dds.party.exception.PartySubTypeNotActiveEx
|
||||
import org.apache.fineract.portfolio.dds.party.serialization.PartyCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransaction;
|
||||
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
|
||||
import org.apache.fineract.portfolio.savings.data.SavingsAccountDataDTO;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.orm.jpa.JpaSystemException;
|
||||
@@ -134,15 +135,20 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
final String partyCode = generateNextPartyCode();
|
||||
final Party party = Party.fromJson(client, agent, officeNamed(command.longValueOfParameterNamed(OFFICE_ID)), partyCode,
|
||||
command);
|
||||
party.takeSchemeRate(schemeOf(party).getInterestRate());
|
||||
final DdsAccountType scheme = schemeOf(party);
|
||||
party.takeSchemeRate(scheme.getInterestRate());
|
||||
party.linkProduct(scheme.getId());
|
||||
// Operative from the day it is opened - by whoever executes this, the checker where the
|
||||
// opening went through maker-checker - in the bank's currency.
|
||||
party.recordActivation(organisationCurrency(), DateUtils.getBusinessLocalDate(), currentUserId());
|
||||
|
||||
linkSavingsAccountIfProvided(party, clientId, command);
|
||||
addNomineesFromCommand(party, command);
|
||||
|
||||
// The party is the DDS account: nothing is opened in the savings tables for it. Its
|
||||
// number comes from its id, so it is given once the first save has made one.
|
||||
this.partyRepository.saveAndFlush(party);
|
||||
party.assignAccountNo();
|
||||
// The party is the DDS account: nothing is opened in the savings tables for it. Its number
|
||||
// is the next on the DDS account serial, taken in this transaction - so an opening that
|
||||
// fails or is rolled back hands its number back, and the numbers run without gaps.
|
||||
party.assignAccountNo(nextAccountSerial());
|
||||
this.partyRepository.saveAndFlush(party);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
@@ -167,6 +173,10 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
this.fromApiJsonDeserializer.validateForUpdate(command.json());
|
||||
|
||||
final Party partyForUpdate = this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId));
|
||||
refuseEditOfClosedAccount(partyForUpdate);
|
||||
// Asked before the update changes anything: is the branch this request names a different one?
|
||||
final boolean officeMoves = command.parameterExists(OFFICE_ID) && (partyForUpdate.getOffice() == null
|
||||
|| !partyForUpdate.getOffice().getId().equals(command.longValueOfParameterNamed(OFFICE_ID)));
|
||||
|
||||
// Checked against whoever owns the party once this update lands, not who owns it now -
|
||||
// moving the party to another customer and naming the old one's account in the same save
|
||||
@@ -176,13 +186,16 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
validateCreditToAccount(owningClientId, command);
|
||||
|
||||
final Map<String, Object> changesOnly = partyForUpdate.update(command);
|
||||
refuseChangeOfLockedTerms(partyForUpdate, changesOnly, officeMoves);
|
||||
|
||||
// Only when it actually changed: a party already sitting on a since-retired account type
|
||||
// must stay editable for everything else.
|
||||
if (changesOnly.containsKey("subType")) {
|
||||
validateSubType((String) changesOnly.get("subType"));
|
||||
// Moved to another scheme: it takes that scheme's rate.
|
||||
partyForUpdate.takeSchemeRate(schemeOf(partyForUpdate).getInterestRate());
|
||||
// Moved to another scheme: it takes that scheme's rate, and is tied to it by id.
|
||||
final DdsAccountType newScheme = schemeOf(partyForUpdate);
|
||||
partyForUpdate.takeSchemeRate(newScheme.getInterestRate());
|
||||
partyForUpdate.linkProduct(newScheme.getId());
|
||||
changesOnly.put("interestRate", partyForUpdate.getInterestRate());
|
||||
}
|
||||
|
||||
@@ -245,7 +258,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
}
|
||||
|
||||
final LocalDate closeDate = command.localDateValueOfParameterNamed("closeDate");
|
||||
party.close(closeDate);
|
||||
party.close(closeDate, currentUserId());
|
||||
this.partyRepository.saveAndFlush(party);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId).build();
|
||||
@@ -347,7 +360,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
// open only leaves a second step to forget - an account that reads as active in every report and
|
||||
// that an agent could still collect on. The same as Maturity Auto Close does, on the day the
|
||||
// money moved.
|
||||
party.close(DateUtils.getBusinessLocalDate());
|
||||
party.close(DateUtils.getBusinessLocalDate(), currentUserId());
|
||||
this.partyRepository.saveAndFlush(party);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId).build();
|
||||
@@ -445,7 +458,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
// The balance is nil now, which is the one thing closing insists on. Closed on the business
|
||||
// date rather than the maturity date: this is the day the money actually moved, and a close
|
||||
// date earlier than its own payout would read as though the account had been shut first.
|
||||
party.close(DateUtils.getBusinessLocalDate());
|
||||
party.close(DateUtils.getBusinessLocalDate(), currentUserId());
|
||||
this.partyRepository.saveAndFlush(party);
|
||||
|
||||
return new CommandProcessingResultBuilder().withEntityId(partyId).build();
|
||||
@@ -678,9 +691,8 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
// Sending one maturity into another moves money between two collection accounts instead of
|
||||
// handing it back, and if that scheme has already closed the money is stranded in an account
|
||||
// nobody can operate. The same refusal an agent's commission account gets.
|
||||
final Long productId = ((Number) account.get("product_id")).longValue();
|
||||
final List<Long> ddsProduct = this.jdbcTemplate.queryForList(DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR
|
||||
if (!ddsProduct.isEmpty() && productId.equals(ddsProduct.get(0))) {
|
||||
final Integer schemes = this.jdbcTemplate.queryForObject(DDS_SCHEME_ACCOUNT_SQL, Integer.class, accountNo); // NOSONAR
|
||||
if (schemes != null && schemes > 0) {
|
||||
throw new PartyCreditAccountUnusableException(accountNo, "it is a DDS scheme account, not an account to be paid into");
|
||||
}
|
||||
|
||||
@@ -705,9 +717,13 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
}
|
||||
}
|
||||
|
||||
/** Set by 0492. Names the savings product a DDS customer's real account is opened under. */
|
||||
private static final String DEPOSIT_PRODUCT_SQL = "select value from c_configuration "
|
||||
+ "where name = 'dds-deposit-product' and enabled = true";
|
||||
/**
|
||||
* Whether a savings account is a DDS customer's scheme account - one a DDS party is linked to.
|
||||
* Read off the parties themselves rather than a configured product id: what makes an account a
|
||||
* scheme account is that a scheme runs on it, whatever product it was opened under.
|
||||
*/
|
||||
private static final String DDS_SCHEME_ACCOUNT_SQL = "select count(*) from m_dds_party p "
|
||||
+ "join m_savings_account a on a.id = p.savings_account_id where a.account_no = ?";
|
||||
|
||||
/**
|
||||
* Opens the customer's real deposit account, unless one was named and linked above.
|
||||
@@ -724,22 +740,10 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
return;
|
||||
}
|
||||
|
||||
final List<Long> productIds = this.jdbcTemplate.queryForList(DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR
|
||||
if (productIds.isEmpty() || productIds.get(0) == null) {
|
||||
throw new DdsDepositProductNotConfiguredException();
|
||||
}
|
||||
|
||||
final SavingsAccountDataDTO accountDetails = new SavingsAccountDataDTO(client, null, productIds.get(0), party.getOpeningDate(),
|
||||
this.context.authenticatedUser(), DateUtils.DEFAULT_DATE_FORMATTER);
|
||||
final CommandProcessingResult result = this.savingsApplicationProcessWritePlatformService.createActiveApplication(accountDetails);
|
||||
final Long savingsAccountId = result.getSavingsId() != null ? result.getSavingsId() : result.getResourceId();
|
||||
party.linkSavingsAccount(savingsAccountId);
|
||||
|
||||
// Withdrawals are blocked from the moment the account exists. A scheme account is a savings
|
||||
// account like any other, and left open a customer could empty a twelve-month scheme at the
|
||||
// counter the next day - which is the one thing daily deposit saving exists to prevent.
|
||||
// Deposits are untouched; only the way out is closed, and DDS lifts it for its own payouts.
|
||||
this.ddsLedger.lockDebits(savingsAccountId);
|
||||
// DDS keeps its customers' accounts itself (0514), with each DDS product's own ledger accounts
|
||||
// behind them, and no savings product is named for DDS any more - so there is no savings
|
||||
// account to open. A party's balance is already on the books through its product.
|
||||
throw new DdsDepositProductNotConfiguredException();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -750,6 +754,68 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
* counted at another. Refuses an id that names no branch rather than leaving the account
|
||||
* unplaced.
|
||||
*/
|
||||
/**
|
||||
* Who is doing this, for the account's own record of it. A background run has no logged-in user;
|
||||
* it is recorded as the system administrator, as the audit columns record it (AuditorAwareImpl).
|
||||
*/
|
||||
private Long currentUserId() {
|
||||
final AppUser user = this.context.getAuthenticatedUserIfPresent();
|
||||
return user != null ? user.getId() : SYSTEM_ADMIN_USER_ID;
|
||||
}
|
||||
|
||||
private static final Long SYSTEM_ADMIN_USER_ID = 1L;
|
||||
|
||||
/** The bank's currency - a DDS account is opened in it. */
|
||||
private String organisationCurrency() {
|
||||
final List<String> codes = this.jdbcTemplate.queryForList("select code from m_organisation_currency order by id limit 1", // NOSONAR
|
||||
String.class);
|
||||
return codes.isEmpty() ? null : codes.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* What an active DDS account's scheme is built on, and so cannot change while it runs - as a savings
|
||||
* account's product, currency and opening cannot: the customer and product, the branch whose books
|
||||
* carry it, the opening and its balance, and the deposit terms (daily amount, deposit, days, months)
|
||||
* that its maturity amount and interest are worked out from, with the dates and amounts that follow
|
||||
* from them. Changing one mid-scheme would leave the balance, the interest already posted and the
|
||||
* maturity figure answering to terms the account no longer has. The collecting agent is locked too:
|
||||
* the agent's pending collections, commission and cash in hand are counted against the account.
|
||||
* Contact details, nominees, lien and freeze, SMS/e-mail and where the maturity is paid stay editable.
|
||||
*/
|
||||
private static final List<String> LOCKED_WHEN_ACTIVE = List.of("clientId", "subType", "agentId", "openingDate", "openingBalance",
|
||||
"dailyDepositAmount", "depositAmount", "days", "month", "interestDate", "payDate", "paymentAmount", "reopenDate",
|
||||
"lastAccount");
|
||||
|
||||
private void refuseEditOfClosedAccount(final Party party) {
|
||||
if (party.isClosed()) {
|
||||
final String message = "DDS account " + party.getAccountNo() + " is closed and cannot be edited.";
|
||||
throw new PlatformApiDataValidationException("error.msg.dds.party.closed.not.editable", message,
|
||||
List.of(ApiParameterError.parameterError("error.msg.dds.party.closed.not.editable", message, "status")));
|
||||
}
|
||||
}
|
||||
|
||||
private void refuseChangeOfLockedTerms(final Party party, final Map<String, Object> changes, final boolean officeMoves) {
|
||||
if (!Party.STATUS_ACTIVE.equals(party.getStatus())) {
|
||||
return;
|
||||
}
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
for (final String field : LOCKED_WHEN_ACTIVE) {
|
||||
if (changes.containsKey(field)) {
|
||||
errors.add(ApiParameterError.parameterError("error.msg.dds.party.term.locked." + field,
|
||||
"'" + field + "' cannot be changed on an active DDS account.", field));
|
||||
}
|
||||
}
|
||||
if (officeMoves) {
|
||||
errors.add(ApiParameterError.parameterError("error.msg.dds.party.term.locked." + OFFICE_ID,
|
||||
"The branch cannot be changed on an active DDS account.", OFFICE_ID));
|
||||
}
|
||||
if (!errors.isEmpty()) {
|
||||
// Thrown inside the update's transaction, so nothing the request changed is kept.
|
||||
throw new PlatformApiDataValidationException("error.msg.dds.party.terms.locked",
|
||||
"These cannot be changed on an active DDS account: " + errors.size() + " field(s).", errors);
|
||||
}
|
||||
}
|
||||
|
||||
private Office officeNamed(final Long officeId) {
|
||||
return this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
|
||||
}
|
||||
@@ -884,7 +950,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
|
||||
payOutRemainder(party, command, closeDate, lockinOverrideReason);
|
||||
|
||||
party.close(closeDate);
|
||||
party.close(closeDate, currentUserId());
|
||||
this.partyRepository.saveAndFlush(party);
|
||||
|
||||
final Map<String, Object> changes = new HashMap<>();
|
||||
@@ -1070,6 +1136,20 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The next DDS account number. The update locks the serial's one row until this transaction ends,
|
||||
* so two openings at once queue on it rather than taking the same number, and a rollback undoes the
|
||||
* increment - unlike a sequence, which never gives a number back.
|
||||
*/
|
||||
private long nextAccountSerial() {
|
||||
final Long next = this.jdbcTemplate.queryForObject( // NOSONAR
|
||||
"update m_dds_account_number_serial set last_value = last_value + 1 where id = 1 returning last_value", Long.class);
|
||||
if (next == null) {
|
||||
throw new IllegalStateException("m_dds_account_number_serial has no row - migration 0624 has not run");
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
private String generateNextPartyCode() {
|
||||
final Long maxSuffix = this.partyRepository.retrieveMaxPartyCodeSuffix();
|
||||
final long nextSuffix = (maxSuffix == null ? 0L : maxSuffix) + 1L;
|
||||
|
||||
+14
@@ -22,6 +22,7 @@ import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import java.lang.reflect.Type;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
@@ -161,6 +162,7 @@ public final class PartyTransactionCommandFromApiJsonDeserializer {
|
||||
} else {
|
||||
v.reset().parameter(COLLECTIONS).parameterAtIndexArray("amount", i).value(extractBigDecimal(row, "amount"))
|
||||
.notNull().positiveAmount();
|
||||
toThePaisa(v, extractBigDecimal(row, "amount"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,9 +173,11 @@ public final class PartyTransactionCommandFromApiJsonDeserializer {
|
||||
.notBlank();
|
||||
v.reset().parameter("withdrawAmount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("withdrawAmount", element))
|
||||
.notNull().positiveAmount();
|
||||
toThePaisa(v, this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("withdrawAmount", element));
|
||||
} else if ("AGENT_TRAN".equals(transactionType)) {
|
||||
v.reset().parameter("amount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("amount", element)).notNull()
|
||||
.positiveAmount();
|
||||
toThePaisa(v, this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("amount", element));
|
||||
|
||||
// An agent handing its round's takings to the branch is a counter transaction like any
|
||||
// other, so it says how the money arrived on the same terms a collection does.
|
||||
@@ -207,4 +211,14 @@ public final class PartyTransactionCommandFromApiJsonDeserializer {
|
||||
dataValidationErrors);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Money is kept to the paisa, as a savings account keeps it: 100.555 is refused rather than stored
|
||||
* on the passbook and the journal as typed.
|
||||
*/
|
||||
private static void toThePaisa(final DataValidatorBuilder v, final BigDecimal amount) {
|
||||
if (amount != null && amount.stripTrailingZeros().scale() > 2) {
|
||||
v.failWithCodeAndMessage("more.than.two.decimals", "An amount can have at most two decimal places (paise).", amount);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+14
-2
@@ -45,9 +45,13 @@ public class PartyTransactionReadPlatformServiceImpl implements PartyTransaction
|
||||
+ " t.agent_id as agentId, a.agent_code as agentCode, t.transaction_date as transactionDate, "
|
||||
+ " t.transaction_type as transactionType, t.direction as direction, t.transaction_mode as transactionMode, "
|
||||
+ " t.account_type as accountType, t.account_no as accountNo, t.amount as amount, t.cheque_no as chequeNo, "
|
||||
+ " t.token_no as tokenNo, t.description as description, t.balance_after_transaction as balanceAfterTransaction, t.voucher_no as voucherNo "
|
||||
+ " t.token_no as tokenNo, t.description as description, t.balance_after_transaction as balanceAfterTransaction, t.voucher_no as voucherNo, "
|
||||
+ " t.office_id as officeId, o.name as officeName, t.submitted_on_date as submittedOnDate, "
|
||||
+ " t.journal_transaction_id as journalTransactionId, t.ref_no as refNo, "
|
||||
+ " u.username as createdByUsername, t.created_date as createdDate, a.agent_name as agentName "
|
||||
+ " from m_dds_party_transaction t left join m_dds_party p on p.id = t.party_id "
|
||||
+ " join m_dds_agent a on a.id = t.agent_id";
|
||||
+ " join m_dds_agent a on a.id = t.agent_id left join m_office o on o.id = t.office_id "
|
||||
+ " left join m_appuser u on u.id = t.createdby_id";
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -71,6 +75,14 @@ public class PartyTransactionReadPlatformServiceImpl implements PartyTransaction
|
||||
.description(rs.getString("description")) //
|
||||
.balanceAfterTransaction(rs.getBigDecimal("balanceAfterTransaction")) //
|
||||
.voucherNo(rs.getString("voucherNo")) //
|
||||
.officeId(JdbcSupport.getLong(rs, "officeId")) //
|
||||
.officeName(rs.getString("officeName")) //
|
||||
.submittedOnDate(JdbcSupport.getLocalDate(rs, "submittedOnDate")) //
|
||||
.journalTransactionId(rs.getString("journalTransactionId")) //
|
||||
.refNo(rs.getString("refNo")) //
|
||||
.createdByUsername(rs.getString("createdByUsername")) //
|
||||
.createdDate(rs.getTimestamp("createdDate") == null ? null : rs.getTimestamp("createdDate").toLocalDateTime()) //
|
||||
.agentName(rs.getString("agentName")) //
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
+6
-23
@@ -53,7 +53,6 @@ import org.apache.fineract.portfolio.dds.party.exception.PartyNotFoundException;
|
||||
import org.apache.fineract.portfolio.dds.transaction.domain.DdsTransactionMode;
|
||||
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransaction;
|
||||
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
|
||||
import org.apache.fineract.portfolio.dds.transaction.exception.InsufficientAgentBalanceException;
|
||||
import org.apache.fineract.portfolio.dds.transaction.exception.PartyAdvanceAmountMismatchException;
|
||||
import org.apache.fineract.portfolio.dds.transaction.exception.PartyArrearsDaysExceededException;
|
||||
import org.apache.fineract.portfolio.dds.transaction.exception.PartyHasNoDailyAmountException;
|
||||
@@ -276,28 +275,12 @@ public class PartyTransactionWritePlatformServiceJpaRepositoryImpl implements Pa
|
||||
// receipt tagged DDS into the agent's own GL account - and applied to the customers from Agent
|
||||
// Tran > Apply DDS Receipt. Taking the same cash in here as well would be a second way in for
|
||||
// one bag of notes, so this way is closed, and says where the right one is.
|
||||
if (agent != null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.dds.agent.tran.moved.to.cashier",
|
||||
"An agent's cash is no longer taken in here. The cashier receipts it at Cashier > Transaction Entry "
|
||||
+ "(Receipt, Cash, GL, Entry Type DDS, A/C No " + (agent.getGlAccount() != null ? agent.getGlAccount().getGlCode()
|
||||
: "the agent's GL account") + "), and it is applied from Daily Transaction Detail > Agent Tran > "
|
||||
+ "Apply DDS Receipt.");
|
||||
}
|
||||
final BigDecimal amount = command.bigDecimalValueOfParameterNamed("amount");
|
||||
if (agent.getAgentCurrentBalance().compareTo(amount) < 0) {
|
||||
throw new InsufficientAgentBalanceException(agent.getAgentCode());
|
||||
}
|
||||
final BigDecimal newBalance = agent.settle(amount);
|
||||
|
||||
// The cash reaches the branch. No customer is involved - they were credited when their money
|
||||
// was collected; this is the bank catching up with where the notes actually are.
|
||||
this.ddsLedger.settle(agent.getOffice(), transactionDate, amount, "DDS settlement by " + agent.getAgentCode(), agent.getGlAccount());
|
||||
|
||||
return new PartyTransaction(null, agent, transactionDate, "AGENT_TRAN", "DEBIT",
|
||||
DdsTransactionMode.canonicalOrOriginal(command.stringValueOfParameterNamedAllowingNull("transactionMode")),
|
||||
command.stringValueOfParameterNamedAllowingNull("accountType"),
|
||||
command.stringValueOfParameterNamedAllowingNull("accountNo"), amount, null, null,
|
||||
command.stringValueOfParameterNamedAllowingNull("description"), newBalance);
|
||||
throw new PlatformDataIntegrityException("error.msg.dds.agent.tran.moved.to.cashier",
|
||||
"An agent's cash is no longer taken in here. The cashier receipts it at Cashier > Transaction Entry "
|
||||
+ "(Receipt, Cash, GL, Entry Type DDS, A/C No " + (agent != null && agent.getGlAccount() != null
|
||||
? agent.getGlAccount().getGlCode()
|
||||
: "the agent's GL account") + "), and it is applied from Daily Transaction Detail > Agent Tran > "
|
||||
+ "Apply DDS Receipt.");
|
||||
}
|
||||
|
||||
private List<PartyTransaction> postWithdrawDeposite(final Agent agent, final LocalDate transactionDate,
|
||||
|
||||
@@ -189,4 +189,12 @@
|
||||
<include file="parts/0606_add_ctr_monthly_report_job.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0607_cc_relevant_risk_rules.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0608_apply_rd_base_chart_to_rd_products.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0618_drop_dds_account_type_post_day.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/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>
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<?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">
|
||||
|
||||
<!--
|
||||
Retires the DDS account type's posting day.
|
||||
|
||||
Automatic interest now posts at the end of each period - the last day of the month, quarter,
|
||||
half-year or year - the way savings and fixed deposits post, so a scheme no longer names a day.
|
||||
A fixed day could not mean the same thing every month: 31 does not exist in February, and a
|
||||
mid-month day split the month-end balance across two postings. Nothing reads the column any
|
||||
more, so it goes rather than being left to look as though it still does something.
|
||||
-->
|
||||
<changeSet id="0618_drop_dds_account_type_post_day" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<columnExists tableName="m_dds_account_type" columnName="auto_interest_post_day"/>
|
||||
</preConditions>
|
||||
<dropColumn tableName="m_dds_account_type" columnName="auto_interest_post_day"/>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
<?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 the four ledger accounts DDS added early on into the chart's own numbering.
|
||||
|
||||
Every other account in the chart carries a six-digit code - the first digit says what kind it is
|
||||
(1 asset, 2 liability, 3 equity, 4 income, 5 expense) - but these four were given 2.1, 9.7, 11.2
|
||||
and 11.3, which sort among nothing and read as nothing on a trial balance.
|
||||
|
||||
Only the code changes. DDS finds each account through the setting that holds its id, never by
|
||||
its code, and journal entries hold the id as well, so nothing posted or configured is touched.
|
||||
An account is renumbered only while it still has the code it was created with, and only if the
|
||||
new code is free, so a code the bank has already changed by hand - or taken for something else -
|
||||
is left alone rather than overwritten or doubled.
|
||||
-->
|
||||
<changeSet id="0619_dds_agent_cash_six_digit_code" author="CBS">
|
||||
<sql>
|
||||
update acc_gl_account
|
||||
set gl_code = '100701'
|
||||
where gl_code = '2.1'
|
||||
and id = (select value from c_configuration where name = 'dds-agent-cash-account')
|
||||
and not exists (select 1 from acc_gl_account where gl_code = '100701');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0619_dds_customer_deposits_six_digit_code" author="CBS">
|
||||
<sql>
|
||||
update acc_gl_account
|
||||
set gl_code = '200601'
|
||||
where gl_code = '9.7'
|
||||
and id = (select value from c_configuration where name = 'dds-deposit-liability-account')
|
||||
and not exists (select 1 from acc_gl_account where gl_code = '200601');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0619_dds_commission_expense_six_digit_code" author="CBS">
|
||||
<sql>
|
||||
update acc_gl_account
|
||||
set gl_code = '500601'
|
||||
where gl_code = '11.2'
|
||||
and id = (select value from c_configuration where name = 'dds-agent-commission-expense-account')
|
||||
and not exists (select 1 from acc_gl_account where gl_code = '500601');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0619_dds_interest_expense_six_digit_code" author="CBS">
|
||||
<sql>
|
||||
update acc_gl_account
|
||||
set gl_code = '500602'
|
||||
where gl_code = '11.3'
|
||||
and id = (select value from c_configuration where name = 'dds-interest-expense-account')
|
||||
and not exists (select 1 from acc_gl_account where gl_code = '500602');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
The same four accounts, named in capitals like every other account in the chart
|
||||
(DDS Agent Cash becomes DDS AGENT CASH). Found through the same settings as above, so only DDS's
|
||||
own accounts are touched, and whether or not the code above had to be left alone.
|
||||
-->
|
||||
<changeSet id="0619_dds_gl_account_names_in_capitals" author="CBS">
|
||||
<sql>
|
||||
update acc_gl_account
|
||||
set name = upper(name)
|
||||
where id in (select value from c_configuration
|
||||
where name in ('dds-agent-cash-account', 'dds-deposit-liability-account',
|
||||
'dds-agent-commission-expense-account', 'dds-interest-expense-account')
|
||||
and value is not null)
|
||||
and name <> upper(name);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
A ledger account for an agent, ready to be named on the Agent Master Entry screen.
|
||||
|
||||
An agent's cash passes through an account of their own: the cashier credits it when the agent
|
||||
hands the day's collection over, and Apply DDS Receipt debits it again as the money is placed
|
||||
on the customers' accounts, so what is standing on it is the agent's, not yet allocated. A credit
|
||||
balance held for others is a liability, and that is where it is put - which is also where the
|
||||
screens that must never offer an agent's account for anything else already look for it.
|
||||
|
||||
One agent to an account, so this one is for the first agent and the next ones get 200603 on.
|
||||
Created as a detail account with no parent, like the other DDS accounts, so it can take entries.
|
||||
-->
|
||||
<changeSet id="0619_add_dds_agent_collection_gl_account" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">select count(*) from acc_gl_account where gl_code = '200602'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
insert into acc_gl_account (name, parent_id, hierarchy, gl_code, disabled, manual_journal_entries_allowed,
|
||||
account_usage, classification_enum, description)
|
||||
values ('DDS AGENT COLLECTION - AG0001', null, '.', '200602', false, true, 1, 2,
|
||||
'Collections handed over by one DDS agent and not yet placed on the customers'' accounts. Credited when the cashier receipts the agent''s cash, debited when Apply DDS Receipt allocates it. One agent to an account.');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
<?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">
|
||||
|
||||
<!--
|
||||
Each DDS product names its own ledger accounts on its Accounting tab, the way a savings product
|
||||
does, and DDS posts through those alone - the bank-wide dds-* account settings stop being read.
|
||||
|
||||
Six accounts are the product's: what the bank owes the product's customers (liability), what
|
||||
their interest costs it (expense), what the agents' commission on collecting for it costs it
|
||||
(expense), where a collection is held when the collecting agent has no ledger account of their
|
||||
own (asset or liability), where a matured balance is parked when no account is named to pay it
|
||||
into (liability), and the current-account control a payout into a current account is credited
|
||||
to when the current product's own mapping is ambiguous (liability).
|
||||
|
||||
Existing products are filled from the settings they were posting through until now, so the first
|
||||
posting after this lands exactly where the last one before it did.
|
||||
-->
|
||||
<changeSet id="0620_dds_product_gl_columns" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_dds_account_type" columnName="deposit_liability_gl_id"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_dds_account_type">
|
||||
<column name="deposit_liability_gl_id" type="BIGINT"/>
|
||||
<column name="interest_expense_gl_id" type="BIGINT"/>
|
||||
<column name="commission_expense_gl_id" type="BIGINT"/>
|
||||
<column name="agent_cash_gl_id" type="BIGINT"/>
|
||||
<column name="matured_payable_gl_id" type="BIGINT"/>
|
||||
<column name="current_control_gl_id" type="BIGINT"/>
|
||||
</addColumn>
|
||||
<addForeignKeyConstraint baseTableName="m_dds_account_type" baseColumnNames="deposit_liability_gl_id"
|
||||
constraintName="fk_dds_account_type_deposit_gl" referencedTableName="acc_gl_account"
|
||||
referencedColumnNames="id"/>
|
||||
<addForeignKeyConstraint baseTableName="m_dds_account_type" baseColumnNames="interest_expense_gl_id"
|
||||
constraintName="fk_dds_account_type_interest_gl" referencedTableName="acc_gl_account"
|
||||
referencedColumnNames="id"/>
|
||||
<addForeignKeyConstraint baseTableName="m_dds_account_type" baseColumnNames="commission_expense_gl_id"
|
||||
constraintName="fk_dds_account_type_commission_gl" referencedTableName="acc_gl_account"
|
||||
referencedColumnNames="id"/>
|
||||
<addForeignKeyConstraint baseTableName="m_dds_account_type" baseColumnNames="agent_cash_gl_id"
|
||||
constraintName="fk_dds_account_type_agent_cash_gl" referencedTableName="acc_gl_account"
|
||||
referencedColumnNames="id"/>
|
||||
<addForeignKeyConstraint baseTableName="m_dds_account_type" baseColumnNames="matured_payable_gl_id"
|
||||
constraintName="fk_dds_account_type_matured_payable_gl" referencedTableName="acc_gl_account"
|
||||
referencedColumnNames="id"/>
|
||||
<addForeignKeyConstraint baseTableName="m_dds_account_type" baseColumnNames="current_control_gl_id"
|
||||
constraintName="fk_dds_account_type_current_control_gl" referencedTableName="acc_gl_account"
|
||||
referencedColumnNames="id"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0620_dds_product_gl_backfill" author="CBS">
|
||||
<sql>
|
||||
update m_dds_account_type
|
||||
set deposit_liability_gl_id = (select g.id from c_configuration c join acc_gl_account g on g.id = c.value
|
||||
where c.name = 'dds-deposit-liability-account')
|
||||
where deposit_liability_gl_id is null;
|
||||
update m_dds_account_type
|
||||
set interest_expense_gl_id = (select g.id from c_configuration c join acc_gl_account g on g.id = c.value
|
||||
where c.name = 'dds-interest-expense-account')
|
||||
where interest_expense_gl_id is null;
|
||||
update m_dds_account_type
|
||||
set commission_expense_gl_id = (select g.id from c_configuration c join acc_gl_account g on g.id = c.value
|
||||
where c.name = 'dds-agent-commission-expense-account')
|
||||
where commission_expense_gl_id is null;
|
||||
update m_dds_account_type
|
||||
set agent_cash_gl_id = (select g.id from c_configuration c join acc_gl_account g on g.id = c.value
|
||||
where c.name = 'dds-agent-cash-account')
|
||||
where agent_cash_gl_id is null;
|
||||
update m_dds_account_type
|
||||
set matured_payable_gl_id = (select g.id from c_configuration c join acc_gl_account g on g.id = c.value
|
||||
where c.name = 'dds-matured-payable-account')
|
||||
where matured_payable_gl_id is null;
|
||||
update m_dds_account_type
|
||||
set current_control_gl_id = (select g.id from c_configuration c join acc_gl_account g on g.id = c.value
|
||||
where c.name = 'dds-current-account-control-account')
|
||||
where current_control_gl_id is null;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
Commission is paid to an agent in one sum for collections that may span several products, and
|
||||
each product now books its share on its own commission account. The share is worked out from the
|
||||
collections the paid commission was earned on - those a commission run has counted and no payment
|
||||
has yet covered - so a collection is marked with the run that paid for it.
|
||||
-->
|
||||
<changeSet id="0620_dds_party_transaction_commission_paid_run" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_dds_party_transaction" columnName="commission_paid_batch_run_id"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_dds_party_transaction">
|
||||
<column name="commission_paid_batch_run_id" type="BIGINT"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
The DDS settings on Global Configurations, removed. Every ledger account DDS posts through is now
|
||||
named on the DDS product itself, and copied there above before these go. dds-deposit-product named
|
||||
the savings product DDS customers' accounts were opened under before DDS kept its own (0514);
|
||||
nothing opens one now, and a scheme account is recognised by the DDS party linked to it.
|
||||
-->
|
||||
<changeSet id="0620_dds_remove_configuration_settings" author="CBS">
|
||||
<sql>
|
||||
delete from c_configuration
|
||||
where name in ('dds-deposit-liability-account', 'dds-interest-expense-account', 'dds-agent-commission-expense-account',
|
||||
'dds-agent-cash-account', 'dds-matured-payable-account', 'dds-current-account-control-account',
|
||||
'dds-deposit-product');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
<?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">
|
||||
|
||||
<!--
|
||||
Three of the accounts 0620 put on the DDS product are never posted to, so they come off it:
|
||||
|
||||
agent_cash_gl_id - an agent cannot be saved without a ledger account of their own, so a
|
||||
collection always lands on the agent's account and never on this one.
|
||||
matured_payable_gl_id - the payout and premature-closure screens both require the clerk to pick
|
||||
the Sundry account a balance is parked in, so there is never a default to use.
|
||||
current_control_gl_id - a payout into a current account is credited to that current product's
|
||||
own control account, from the current product's own accounting.
|
||||
-->
|
||||
<changeSet id="0621_dds_product_drop_agent_cash_gl" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<columnExists tableName="m_dds_account_type" columnName="agent_cash_gl_id"/>
|
||||
</preConditions>
|
||||
<dropForeignKeyConstraint baseTableName="m_dds_account_type" constraintName="fk_dds_account_type_agent_cash_gl"/>
|
||||
<dropColumn tableName="m_dds_account_type" columnName="agent_cash_gl_id"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0621_dds_product_drop_matured_payable_gl" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<columnExists tableName="m_dds_account_type" columnName="matured_payable_gl_id"/>
|
||||
</preConditions>
|
||||
<dropForeignKeyConstraint baseTableName="m_dds_account_type" constraintName="fk_dds_account_type_matured_payable_gl"/>
|
||||
<dropColumn tableName="m_dds_account_type" columnName="matured_payable_gl_id"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0621_dds_product_drop_current_control_gl" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<columnExists tableName="m_dds_account_type" columnName="current_control_gl_id"/>
|
||||
</preConditions>
|
||||
<dropForeignKeyConstraint baseTableName="m_dds_account_type" constraintName="fk_dds_account_type_current_control_gl"/>
|
||||
<dropColumn tableName="m_dds_account_type" columnName="current_control_gl_id"/>
|
||||
</changeSet>
|
||||
|
||||
</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