DDS: collection entries held before posting, per-agent GL accounts, maturity auto close
Collections used to reach a customer the moment they were typed, on the reasoning that the money is theirs from the moment the agent takes it. That holds until the agent does not arrive - and then the books say a customer was credited with notes the bank never held, and there was nothing to count the agent's bag against when they did. An agent now records their own round. Those entries sit in m_dds_collection_entry and move nothing: no balance, no ledger, no agent cash. A clerk counts the cash against what they add up to and settles the day, and what that writes is an ordinary collection - posted through the counter's own code rather than a copy of it, so the two cannot drift apart. Each agent may also hold their own ledger account now, one agent to an account. Both sides of the entry, their direction and the amount are unchanged; only which account plays the agent's part differs, and only for agents that name one. What is left standing on an agent's own account is the cash they are holding and have not brought in - a question nobody could answer while every agent shared a single account. A scheme can close its own accounts on maturity, for accounts whose customer named where the proceeds go. The run is generated and reviewed before anything moves, and an account that named no destination is simply not on the list and is paid out by hand as before. Also: the payment mode is a fixed set and stored one way; an agent's birth date must be in the past and of age; a maturity credit account is checked whenever it is written rather than only when the Adv Code happened to arrive beside it; and two report columns stop printing money ungrouped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
37c961f713
commit
12fdf035ca
+81
@@ -680,6 +680,14 @@ public class CommandWrapperBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder closeAgent(final Long agentId) {
|
||||
this.actionName = "CLOSE";
|
||||
this.entityName = "AGENT";
|
||||
this.entityId = agentId;
|
||||
this.href = "/dds/agents/" + agentId + "?command=close";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createParty() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "PARTY";
|
||||
@@ -704,6 +712,47 @@ public class CommandWrapperBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pays a matured DDS account out and closes it. A separate action from CLOSE because it moves
|
||||
* money, and so is worth its own permission and its own line in the audit trail.
|
||||
*/
|
||||
public CommandWrapperBuilder payOutParty(final Long partyId) {
|
||||
this.actionName = "PAYOUT";
|
||||
this.entityName = "PARTY";
|
||||
this.entityId = partyId;
|
||||
this.href = "/dds/parties/" + partyId + "?command=payout";
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes a DDS account before its term is up, settling the shortfall against the customer or the
|
||||
* agent. Separate from both CLOSE and PAYOUT because it is the only one of the three that changes
|
||||
* what a customer is owed, so a bank will want it approved by someone other than whoever raised it.
|
||||
*/
|
||||
public CommandWrapperBuilder prematureCloseParty(final Long partyId) {
|
||||
this.actionName = "PREMATURECLOSE";
|
||||
this.entityName = "PARTY";
|
||||
this.entityId = partyId;
|
||||
this.href = "/dds/parties/" + partyId + "?command=prematureclose";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createDdsAccountType() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "DDSACCOUNTTYPE";
|
||||
this.entityId = null;
|
||||
this.href = "/dds/accounttypes/template";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateDdsAccountType(final Long accountTypeId) {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "DDSACCOUNTTYPE";
|
||||
this.entityId = accountTypeId;
|
||||
this.href = "/dds/accounttypes/" + accountTypeId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createPartyTransaction() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "PARTYTRANSACTION";
|
||||
@@ -712,6 +761,30 @@ public class CommandWrapperBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createDdsCollectionEntries() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "DDSCOLLECTIONENTRY";
|
||||
this.entityId = null;
|
||||
this.href = "/dds/collectionentries/template";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder cancelDdsCollectionEntry(final Long entryId) {
|
||||
this.actionName = "CANCEL";
|
||||
this.entityName = "DDSCOLLECTIONENTRY";
|
||||
this.entityId = entryId;
|
||||
this.href = "/dds/collectionentries/" + entryId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder settleDdsCollectionEntries() {
|
||||
this.actionName = "SETTLE";
|
||||
this.entityName = "DDSCOLLECTIONENTRY";
|
||||
this.entityId = null;
|
||||
this.href = "/dds/collectionentries/settle";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createCommissionSlabs() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "COMMISSIONSLAB";
|
||||
@@ -720,6 +793,14 @@ public class CommandWrapperBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateCommissionSlab(final Long slabId) {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "COMMISSIONSLAB";
|
||||
this.entityId = slabId;
|
||||
this.href = "/dds/commissionslabs/" + slabId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder generateDdsBatchRun() {
|
||||
this.actionName = "GENERATE";
|
||||
this.entityName = "DDSBATCH";
|
||||
|
||||
+1
@@ -68,6 +68,7 @@ public enum JobName {
|
||||
OUTWARD_CLEARING_BATCH_SEND("CTS Outward Clearing Batch Send"), //
|
||||
CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), //
|
||||
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
|
||||
POST_DDS_INTEREST("Post DDS Interest"), //
|
||||
; //
|
||||
|
||||
private final String name;
|
||||
|
||||
+41
-90
@@ -22,15 +22,21 @@ import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Immutable data object representing a DDS agent, including its collection-limit history and
|
||||
* guarantors. Carries the collection/commission/security-account fields merged in from SubAgent
|
||||
* (2026-08-24) - Agent now does the daily collection work directly.
|
||||
* Immutable data object representing a DDS agent and its guarantors, following the legacy Agent
|
||||
* Master Entry screen. Built via the Lombok builder rather than a long positional constructor, as the
|
||||
* other DDS data objects are.
|
||||
*
|
||||
* The commission block below is on the legacy SubAgent Master screen rather than the Agent one, and
|
||||
* drives the commission batch.
|
||||
*/
|
||||
@Getter
|
||||
@Builder(toBuilder = true)
|
||||
public final class AgentData implements Serializable {
|
||||
|
||||
@Serial
|
||||
@@ -52,97 +58,42 @@ public final class AgentData implements Serializable {
|
||||
private final LocalDate agentBirthDate;
|
||||
private final BigDecimal agentCurrentBalance;
|
||||
private final BigDecimal agentOpeningBalance;
|
||||
|
||||
/**
|
||||
* The branch this agent belongs to. {@code officeId} is what the screen sends back on an edit;
|
||||
* {@code homeBranch} is that office's current name, read from the office row rather than from
|
||||
* the agent, so a renamed branch reads correctly here without the agent being touched.
|
||||
*/
|
||||
private final Long officeId;
|
||||
private final String homeBranch;
|
||||
private final String savingBranch;
|
||||
private final Boolean tdsApplicable;
|
||||
private final BigDecimal tdsPercent;
|
||||
private final BigDecimal currentTds;
|
||||
private final BigDecimal tdsAmount;
|
||||
private final String transferBranch;
|
||||
private final String transferAmountAccountType;
|
||||
private final String transferAccountNo;
|
||||
private final Boolean commissionCalculation;
|
||||
private final BigDecimal limit;
|
||||
private final BigDecimal machineRent;
|
||||
private final String agentSecurityBranch;
|
||||
private final String agentSecurityAccountType;
|
||||
private final String agentSecurityAccountNo;
|
||||
|
||||
/**
|
||||
* The ledger account this agent's collections pass through, if the bank gave them one. The id is
|
||||
* what the screen sends back on an edit; the code and name are what it shows.
|
||||
*/
|
||||
private final Long glAccountId;
|
||||
private final String glAccountCode;
|
||||
private final String glAccountName;
|
||||
|
||||
/** Commission owed but not yet paid out, and the TDS withheld so far. Both are batch outcomes. */
|
||||
private final BigDecimal commission;
|
||||
private final BigDecimal otherCommission;
|
||||
private final BigDecimal tdsAmount;
|
||||
|
||||
/** Commission settings and the standing figures the commission run deducts. */
|
||||
private final boolean commissionCalculation;
|
||||
private final boolean tdsApplicable;
|
||||
private final BigDecimal tdsPercent;
|
||||
private final BigDecimal deductCommission;
|
||||
private final BigDecimal agentDeposit;
|
||||
private final BigDecimal festivalAdvance;
|
||||
private final BigDecimal otherDeduction;
|
||||
private final Collection<AgentLimitData> limits;
|
||||
|
||||
private final boolean active;
|
||||
|
||||
/** When the relationship ended, or null while the agent is on the books. */
|
||||
private final LocalDate closeDate;
|
||||
|
||||
private final LocalDateTime createdDate;
|
||||
private final LocalDateTime lastModifiedDate;
|
||||
private final Collection<AgentGuarantorData> guarantors;
|
||||
|
||||
public static AgentData instance(final Long id, final Long clientId, final String clientName, final String agentCode,
|
||||
final String agentNameId, final String agentAddress1, final String agentAddress2, final String agentCity,
|
||||
final String agentPinCode, final String agentTelephone, final String agentSavingAccountType,
|
||||
final String agentSavingAccountNo, final LocalDate agentOpeningDate, final LocalDate agentBirthDate,
|
||||
final BigDecimal agentCurrentBalance, final BigDecimal agentOpeningBalance, final String homeBranch,
|
||||
final String savingBranch, final Boolean tdsApplicable, final BigDecimal tdsPercent, final BigDecimal currentTds,
|
||||
final BigDecimal tdsAmount, final String transferBranch, final String transferAmountAccountType,
|
||||
final String transferAccountNo, final Boolean commissionCalculation, final BigDecimal limit, final BigDecimal machineRent,
|
||||
final String agentSecurityBranch, final String agentSecurityAccountType, final String agentSecurityAccountNo,
|
||||
final BigDecimal commission, final BigDecimal otherCommission, final BigDecimal agentDeposit,
|
||||
final BigDecimal festivalAdvance, final BigDecimal otherDeduction, final Collection<AgentLimitData> limits,
|
||||
final Collection<AgentGuarantorData> guarantors) {
|
||||
return new AgentData(id, clientId, clientName, agentCode, agentNameId, agentAddress1, agentAddress2, agentCity, agentPinCode,
|
||||
agentTelephone, agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate, agentCurrentBalance,
|
||||
agentOpeningBalance, homeBranch, savingBranch, tdsApplicable, tdsPercent, currentTds, tdsAmount, transferBranch,
|
||||
transferAmountAccountType, transferAccountNo, commissionCalculation, limit, machineRent, agentSecurityBranch,
|
||||
agentSecurityAccountType, agentSecurityAccountNo, commission, otherCommission, agentDeposit, festivalAdvance,
|
||||
otherDeduction, limits, guarantors);
|
||||
}
|
||||
|
||||
private AgentData(final Long id, final Long clientId, final String clientName, final String agentCode, final String agentNameId,
|
||||
final String agentAddress1, final String agentAddress2, final String agentCity, final String agentPinCode,
|
||||
final String agentTelephone, final String agentSavingAccountType, final String agentSavingAccountNo,
|
||||
final LocalDate agentOpeningDate, final LocalDate agentBirthDate, final BigDecimal agentCurrentBalance,
|
||||
final BigDecimal agentOpeningBalance, final String homeBranch, final String savingBranch, final Boolean tdsApplicable,
|
||||
final BigDecimal tdsPercent, final BigDecimal currentTds, final BigDecimal tdsAmount, final String transferBranch,
|
||||
final String transferAmountAccountType, final String transferAccountNo, final Boolean commissionCalculation,
|
||||
final BigDecimal limit, final BigDecimal machineRent, final String agentSecurityBranch,
|
||||
final String agentSecurityAccountType, final String agentSecurityAccountNo, final BigDecimal commission,
|
||||
final BigDecimal otherCommission, final BigDecimal agentDeposit, final BigDecimal festivalAdvance,
|
||||
final BigDecimal otherDeduction, final Collection<AgentLimitData> limits, final Collection<AgentGuarantorData> guarantors) {
|
||||
this.id = id;
|
||||
this.clientId = clientId;
|
||||
this.clientName = clientName;
|
||||
this.agentCode = agentCode;
|
||||
this.agentNameId = agentNameId;
|
||||
this.agentAddress1 = agentAddress1;
|
||||
this.agentAddress2 = agentAddress2;
|
||||
this.agentCity = agentCity;
|
||||
this.agentPinCode = agentPinCode;
|
||||
this.agentTelephone = agentTelephone;
|
||||
this.agentSavingAccountType = agentSavingAccountType;
|
||||
this.agentSavingAccountNo = agentSavingAccountNo;
|
||||
this.agentOpeningDate = agentOpeningDate;
|
||||
this.agentBirthDate = agentBirthDate;
|
||||
this.agentCurrentBalance = agentCurrentBalance;
|
||||
this.agentOpeningBalance = agentOpeningBalance;
|
||||
this.homeBranch = homeBranch;
|
||||
this.savingBranch = savingBranch;
|
||||
this.tdsApplicable = tdsApplicable;
|
||||
this.tdsPercent = tdsPercent;
|
||||
this.currentTds = currentTds;
|
||||
this.tdsAmount = tdsAmount;
|
||||
this.transferBranch = transferBranch;
|
||||
this.transferAmountAccountType = transferAmountAccountType;
|
||||
this.transferAccountNo = transferAccountNo;
|
||||
this.commissionCalculation = commissionCalculation;
|
||||
this.limit = limit;
|
||||
this.machineRent = machineRent;
|
||||
this.agentSecurityBranch = agentSecurityBranch;
|
||||
this.agentSecurityAccountType = agentSecurityAccountType;
|
||||
this.agentSecurityAccountNo = agentSecurityAccountNo;
|
||||
this.commission = commission;
|
||||
this.otherCommission = otherCommission;
|
||||
this.agentDeposit = agentDeposit;
|
||||
this.festivalAdvance = festivalAdvance;
|
||||
this.otherDeduction = otherDeduction;
|
||||
this.limits = limits;
|
||||
this.guarantors = guarantors;
|
||||
}
|
||||
}
|
||||
|
||||
+249
-166
@@ -34,21 +34,32 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
import lombok.Getter;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
import org.apache.fineract.organisation.office.domain.Office;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
|
||||
/**
|
||||
* A DDS (Daily Deposit Scheme) collection agent. Can represent either a person or, at the top of the
|
||||
* hierarchy, the bank itself acting as its own agent. Holds its own settlement savings account and one
|
||||
* or more guarantors.
|
||||
*
|
||||
* The field set follows the legacy Agent Master Entry screen. SubAgent was folded into Agent
|
||||
* (2026-08-24) so the Agent now does the daily collection work directly, but the SubAgent-only fields
|
||||
* that came across with it (TDS, transfer/security accounts, machine rent, collection-limit history)
|
||||
* were on no screen the business uses and were read by no query, report or job, so they were dropped
|
||||
* again in 0481. Two fields are kept despite not appearing on that screen, because code reads them:
|
||||
* {@code commission}/{@code lastCommissionDate}, and the deduction figures the Agent Commission
|
||||
* Statement reports.
|
||||
*/
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "m_dds_agent", uniqueConstraints = { @UniqueConstraint(columnNames = { "agent_code" }, name = "agent_code_UNIQUE") })
|
||||
public class Agent extends AbstractPersistableCustom<Long> {
|
||||
public class Agent extends AbstractAuditableCustom {
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "client_id", nullable = false)
|
||||
@@ -93,60 +104,68 @@ public class Agent extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "agent_opening_balance", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal agentOpeningBalance;
|
||||
|
||||
// Fields below were merged in from SubAgent (2026-08-24) - the Agent itself now does the daily
|
||||
// collection work directly; SubAgent no longer exists as a separate tier. Ag.Level / Upper Ag.Level
|
||||
// / Collector (MLM) / CRPB Points / Level Update Date were dropped - they only ever meant anything
|
||||
// for a multi-level hierarchy, which no longer exists here.
|
||||
@Column(name = "home_branch", length = 100, nullable = false)
|
||||
/**
|
||||
* The branch this agent belongs to, and the thing of record for it.
|
||||
*
|
||||
* Defaults to the agent's client's office - where the person sits on the bank's books - but is
|
||||
* chosen on the screen, since an agent can be registered at one branch and collect out of
|
||||
* another.
|
||||
*/
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "office_id")
|
||||
private Office office;
|
||||
|
||||
/**
|
||||
* The office's name, held alongside the office itself.
|
||||
*
|
||||
* Redundant by design: the batch job's branch filter and several reports match on this name, and
|
||||
* were written before the office link existed. It is always written from {@link #office}, never
|
||||
* on its own, so the two cannot drift apart.
|
||||
*/
|
||||
@Column(name = "home_branch", length = 100)
|
||||
private String homeBranch;
|
||||
|
||||
@Column(name = "saving_branch", length = 100, nullable = false)
|
||||
private String savingBranch;
|
||||
|
||||
@Column(name = "tds_applicable", nullable = false)
|
||||
private boolean tdsApplicable;
|
||||
|
||||
@Column(name = "tds_percent", scale = 6, precision = 19)
|
||||
private BigDecimal tdsPercent;
|
||||
|
||||
@Column(name = "current_tds", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal currentTds;
|
||||
|
||||
@Column(name = "tds_amount", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal tdsAmount;
|
||||
|
||||
@Column(name = "transfer_branch", length = 100)
|
||||
private String transferBranch;
|
||||
|
||||
@Column(name = "transfer_amount_account_type", length = 100)
|
||||
private String transferAmountAccountType;
|
||||
|
||||
@Column(name = "transfer_account_no", length = 50)
|
||||
private String transferAccountNo;
|
||||
|
||||
@Column(name = "collector_commission_calculation", nullable = false)
|
||||
private boolean commissionCalculation;
|
||||
|
||||
@Column(name = "collector_limit_amount", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal limitAmount;
|
||||
|
||||
@Column(name = "machine_rent", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal machineRent;
|
||||
|
||||
@Column(name = "agent_security_branch", length = 100)
|
||||
private String agentSecurityBranch;
|
||||
|
||||
@Column(name = "agent_security_account_type", length = 100)
|
||||
private String agentSecurityAccountType;
|
||||
|
||||
@Column(name = "agent_security_account_no", length = 50)
|
||||
private String agentSecurityAccountNo;
|
||||
/**
|
||||
* The ledger account this agent's collections pass through, if the bank has given them one.
|
||||
*
|
||||
* Collections debit it on their way to the customer and settlements credit it when the cash
|
||||
* arrives, so what is left standing on it is what this agent is holding and has not brought in.
|
||||
* One agent to an account - shared, it would say nothing that the old single account did not.
|
||||
*
|
||||
* Null for the agents that predate this, and they go on using the scheme's shared agent-cash
|
||||
* account exactly as before. Nothing about the route the money takes depends on which of the
|
||||
* two it is.
|
||||
*/
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "gl_account_id")
|
||||
private GLAccount glAccount;
|
||||
|
||||
/**
|
||||
* Commission earned by Commission Calculation but not yet paid out. Commission Posting pays it and
|
||||
* resets this to zero, so it reads as "owed to this agent right now" - which is what the legacy
|
||||
* SubAgent Master screen's "Comm" box shows.
|
||||
*/
|
||||
@Column(name = "commission", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal commission;
|
||||
|
||||
@Column(name = "other_commission", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal otherCommission;
|
||||
/** Whether Commission Calculation covers this agent at all ("Com. Calculation" on the legacy screen). */
|
||||
@Column(name = "commission_calculation", nullable = false)
|
||||
private boolean commissionCalculation;
|
||||
|
||||
/** TDS is withheld from commission at source when the bank has it switched on for this agent. */
|
||||
@Column(name = "tds_applicable", nullable = false)
|
||||
private boolean tdsApplicable;
|
||||
|
||||
@Column(name = "tds_percent", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal tdsPercent;
|
||||
|
||||
/** Running total of TDS withheld, the legacy screen's "TDS Amt". */
|
||||
@Column(name = "tds_amount", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal tdsAmount;
|
||||
|
||||
/** The four figures the Agent Commission Statement subtracts from gross commission. */
|
||||
@Column(name = "deduct_commission", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal deductCommission;
|
||||
|
||||
@Column(name = "agent_deposit", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal agentDeposit;
|
||||
@@ -160,13 +179,29 @@ public class Agent extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "last_commission_date")
|
||||
private LocalDate lastCommissionDate;
|
||||
|
||||
@OneToMany(mappedBy = "agent", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||
@OrderBy("id")
|
||||
private List<AgentGuarantor> guarantors = new ArrayList<>();
|
||||
/**
|
||||
* An agent who stops collecting is deactivated, never deleted: every party collection, settlement
|
||||
* and commission posting is recorded against it, so removing it would orphan its whole history.
|
||||
* An inactive agent stays out of the pick lists for new collection work while its record and
|
||||
* history stay intact, and it can be switched back on.
|
||||
*/
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private boolean active;
|
||||
|
||||
/**
|
||||
* When the relationship ended, or null while the agent is on the books.
|
||||
*
|
||||
* Distinct from {@code active}: switching an agent off stops them collecting and has to be
|
||||
* possible immediately, whereas closing is the end of the relationship and is only allowed once
|
||||
* nothing is left hanging. A closed agent is never deleted - their whole collection history is
|
||||
* recorded against them.
|
||||
*/
|
||||
@Column(name = "close_date")
|
||||
private LocalDate closeDate;
|
||||
|
||||
@OneToMany(mappedBy = "agent", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||
@OrderBy("id")
|
||||
private List<AgentLimit> limits = new ArrayList<>();
|
||||
private List<AgentGuarantor> guarantors = new ArrayList<>();
|
||||
|
||||
protected Agent() {
|
||||
//
|
||||
@@ -175,7 +210,7 @@ public class Agent extends AbstractPersistableCustom<Long> {
|
||||
private Agent(final Client client, final String agentCode, final String agentName, final String agentAddress1,
|
||||
final String agentAddress2, final String agentCity, final String agentPinCode, final String agentTelephone,
|
||||
final String agentSavingAccountType, final String agentSavingAccountNo, final LocalDate agentOpeningDate,
|
||||
final LocalDate agentBirthDate, final BigDecimal agentOpeningBalance, final JsonCommand command) {
|
||||
final LocalDate agentBirthDate, final BigDecimal agentOpeningBalance, final Office office) {
|
||||
this.client = client;
|
||||
this.agentCode = agentCode;
|
||||
this.agentName = agentName;
|
||||
@@ -190,34 +225,27 @@ public class Agent extends AbstractPersistableCustom<Long> {
|
||||
this.agentBirthDate = agentBirthDate;
|
||||
this.agentOpeningBalance = agentOpeningBalance == null ? BigDecimal.ZERO : agentOpeningBalance;
|
||||
this.agentCurrentBalance = this.agentOpeningBalance;
|
||||
setOffice(office);
|
||||
this.active = true;
|
||||
|
||||
this.homeBranch = command.stringValueOfParameterNamed("homeBranch");
|
||||
this.savingBranch = command.stringValueOfParameterNamed("savingBranch");
|
||||
this.tdsApplicable = command.booleanPrimitiveValueOfParameterNamed("tdsApplicable");
|
||||
this.tdsPercent = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("tdsPercent");
|
||||
this.currentTds = BigDecimal.ZERO;
|
||||
this.tdsAmount = BigDecimal.ZERO;
|
||||
this.transferBranch = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("transferBranch"), null);
|
||||
this.transferAmountAccountType = StringUtils
|
||||
.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("transferAmountAccountType"), null);
|
||||
this.transferAccountNo = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("transferAccountNo"), null);
|
||||
this.commissionCalculation = command.booleanPrimitiveValueOfParameterNamed("commissionCalculation");
|
||||
this.limitAmount = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("limit"));
|
||||
this.machineRent = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("machineRent"));
|
||||
this.agentSecurityBranch = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("agentSecurityBranch"),
|
||||
null);
|
||||
this.agentSecurityAccountType = StringUtils
|
||||
.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("agentSecurityAccountType"), null);
|
||||
this.agentSecurityAccountNo = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("agentSecurityAccountNo"),
|
||||
null);
|
||||
this.commission = BigDecimal.ZERO;
|
||||
this.otherCommission = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("otherCommission"));
|
||||
this.agentDeposit = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("agentDeposit"));
|
||||
this.festivalAdvance = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("festivalAdvance"));
|
||||
this.otherDeduction = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("otherDeduction"));
|
||||
// Commission is earned unless someone says otherwise, matching the legacy screen's default.
|
||||
this.commissionCalculation = true;
|
||||
this.tdsApplicable = false;
|
||||
this.tdsPercent = BigDecimal.ZERO;
|
||||
this.tdsAmount = BigDecimal.ZERO;
|
||||
this.deductCommission = BigDecimal.ZERO;
|
||||
this.agentDeposit = BigDecimal.ZERO;
|
||||
this.festivalAdvance = BigDecimal.ZERO;
|
||||
this.otherDeduction = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
public static Agent fromJson(final Client client, final String agentCode, final JsonCommand command) {
|
||||
/**
|
||||
* @param office
|
||||
* the branch this agent belongs to, resolved by the caller from the command's
|
||||
* {@code officeId} or, failing that, from the client's own office.
|
||||
*/
|
||||
public static Agent fromJson(final Client client, final String agentCode, final Office office, final JsonCommand command) {
|
||||
final String agentName = command.stringValueOfParameterNamed("agentNameId");
|
||||
final String agentAddress1 = command.stringValueOfParameterNamed("agentAddress1");
|
||||
final String agentAddress2 = command.stringValueOfParameterNamedAllowingNull("agentAddress2");
|
||||
@@ -231,11 +259,7 @@ public class Agent extends AbstractPersistableCustom<Long> {
|
||||
final BigDecimal agentOpeningBalance = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("agentOpeningBalance");
|
||||
|
||||
return new Agent(client, agentCode, agentName, agentAddress1, agentAddress2, agentCity, agentPinCode, agentTelephone,
|
||||
agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate, agentOpeningBalance, command);
|
||||
}
|
||||
|
||||
private static BigDecimal orZero(final BigDecimal value) {
|
||||
return value == null ? BigDecimal.ZERO : value;
|
||||
agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate, agentOpeningBalance, office);
|
||||
}
|
||||
|
||||
public void addGuarantor(final AgentGuarantor guarantor) {
|
||||
@@ -247,29 +271,85 @@ public class Agent extends AbstractPersistableCustom<Long> {
|
||||
this.guarantors.clear();
|
||||
}
|
||||
|
||||
public void addLimit(final AgentLimit limit) {
|
||||
limit.setAgent(this);
|
||||
this.limits.add(limit);
|
||||
}
|
||||
|
||||
public void resetLimits() {
|
||||
this.limits.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a Commission Posting's amount to this agent's running commission total and stamps the date
|
||||
* commission was last calculated through, so the next Commission Calculation batch only covers
|
||||
* collections since then.
|
||||
* Commission Calculation: adds what this agent earned to the amount owed to them, withholds the
|
||||
* TDS that went with it, and stamps the date commission is now calculated through - so the next
|
||||
* Commission Calculation only covers collections since then, and re-running it for the same
|
||||
* period finds nothing left to earn.
|
||||
*/
|
||||
public void postCommission(final BigDecimal amount, final LocalDate throughDate) {
|
||||
this.commission = this.commission.add(amount);
|
||||
public void accrueCommission(final BigDecimal netAmount, final BigDecimal tdsWithheld, final LocalDate throughDate) {
|
||||
this.commission = this.commission.add(netAmount);
|
||||
this.tdsAmount = this.tdsAmount.add(tdsWithheld);
|
||||
this.lastCommissionDate = throughDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commission Posting: pays the given amount out of what is owed and returns what remains owed.
|
||||
*
|
||||
* The amount is the one the run previewed rather than whatever is owed at this moment, so what
|
||||
* gets paid is always what was seen and approved. Since it comes off the owed figure, a second
|
||||
* posting of the same commission finds nothing left to pay.
|
||||
*/
|
||||
public BigDecimal payOutCommission(final BigDecimal amount) {
|
||||
this.commission = this.commission.subtract(amount);
|
||||
return this.commission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Charges the agent for an account of theirs that was closed before its term was up.
|
||||
*
|
||||
* Booked against {@code deductCommission} rather than taken straight off {@code commission},
|
||||
* because what is owed at this moment may be nothing - commission is paid out in runs, and an
|
||||
* agent who was paid yesterday would otherwise escape the charge entirely. Held as a deduction it
|
||||
* is carried until there is commission to take it from, and it shows on the Agent Commission
|
||||
* Statement between gross and take-home, where an agent can see what was charged and why.
|
||||
*/
|
||||
public BigDecimal chargePrematureClosure(final BigDecimal amount) {
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return this.deductCommission;
|
||||
}
|
||||
this.deductCommission = this.deductCommission.add(amount);
|
||||
return this.deductCommission;
|
||||
}
|
||||
|
||||
/**
|
||||
* The four figures the Agent Commission Statement subtracts between gross commission and the
|
||||
* agent's take-home total. They are standing amounts on the agent, not per-run ones, so a run
|
||||
* deducts whatever they hold when it is calculated.
|
||||
*/
|
||||
public BigDecimal totalDeductions() {
|
||||
return this.deductCommission.add(this.agentDeposit).add(this.festivalAdvance).add(this.otherDeduction);
|
||||
}
|
||||
|
||||
public boolean isClosed() {
|
||||
return this.closeDate != null;
|
||||
}
|
||||
|
||||
/** Cash collected from customers that has not yet reached a branch. */
|
||||
public boolean holdsCash() {
|
||||
return this.agentCurrentBalance.compareTo(BigDecimal.ZERO) != 0;
|
||||
}
|
||||
|
||||
/** Commission earned but not yet paid out. */
|
||||
public boolean isOwedCommission() {
|
||||
return this.commission.compareTo(BigDecimal.ZERO) != 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the relationship. The caller checks first that no accounts are still assigned; the two
|
||||
* money conditions are checked here, where the figures live.
|
||||
*
|
||||
* Closing also switches the agent off, since an agent who is no longer on the books cannot be
|
||||
* collecting either.
|
||||
*/
|
||||
public void close(final LocalDate closeDate) {
|
||||
this.closeDate = closeDate;
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cash this agent has collected from parties but not yet handed over to a branch (an Agent Tran
|
||||
* settlement). Raised by a Party Tran collection, lowered by an Agent Tran settlement - this is the
|
||||
* figure an Agent's collection limit is enforced against.
|
||||
* settlement). Raised by a Party Tran collection, lowered by an Agent Tran settlement.
|
||||
*/
|
||||
public BigDecimal credit(final BigDecimal amount) {
|
||||
this.agentCurrentBalance = this.agentCurrentBalance.add(amount);
|
||||
@@ -285,6 +365,34 @@ public class Agent extends AbstractPersistableCustom<Long> {
|
||||
this.client = newClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the agent to a branch, writing the office's name into {@code homeBranch} in the same
|
||||
* step so the name never has to be set by hand and never lags behind the office.
|
||||
*/
|
||||
public void setOffice(final Office office) {
|
||||
this.office = office;
|
||||
this.homeBranch = office == null ? null : StringUtils.defaultIfEmpty(office.getName(), null);
|
||||
}
|
||||
|
||||
public Long officeId() {
|
||||
return this.office == null ? null : this.office.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Points this agent's collections at a ledger account, or back at the scheme's shared one.
|
||||
*
|
||||
* Whether the account may be used at all - that it exists, is an asset account, is not disabled,
|
||||
* and is not already another agent's - is settled before this is called. By the time the agent
|
||||
* holds it, the question is closed.
|
||||
*/
|
||||
public void setGlAccount(final GLAccount glAccount) {
|
||||
this.glAccount = glAccount;
|
||||
}
|
||||
|
||||
public Long glAccountId() {
|
||||
return this.glAccount == null ? null : this.glAccount.getId();
|
||||
}
|
||||
|
||||
public Long clientId() {
|
||||
return this.client.getId();
|
||||
}
|
||||
@@ -355,8 +463,33 @@ public class Agent extends AbstractPersistableCustom<Long> {
|
||||
this.agentBirthDate = command.localDateValueOfParameterNamed("agentBirthDate");
|
||||
}
|
||||
|
||||
stringChange(command, actualChanges, "homeBranch", this.homeBranch, v -> this.homeBranch = v);
|
||||
stringChange(command, actualChanges, "savingBranch", this.savingBranch, v -> this.savingBranch = v);
|
||||
if (command.isChangeInBigDecimalParameterNamed("agentOpeningBalance", this.agentOpeningBalance)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("agentOpeningBalance");
|
||||
actualChanges.put("agentOpeningBalance", newValue);
|
||||
// The current balance is the opening balance plus collections less settlements, so
|
||||
// correcting the opening figure has to move the current one by the same amount.
|
||||
// Setting it alone would leave the two contradicting each other for good, since nothing
|
||||
// else ever recomputes the current balance from scratch.
|
||||
this.agentCurrentBalance = this.agentCurrentBalance.add(newValue.subtract(this.agentOpeningBalance));
|
||||
this.agentOpeningBalance = newValue;
|
||||
actualChanges.put("agentCurrentBalance", this.agentCurrentBalance);
|
||||
}
|
||||
|
||||
if (command.isChangeInBooleanParameterNamed("active", this.active)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("active");
|
||||
actualChanges.put("active", newValue);
|
||||
this.active = newValue;
|
||||
}
|
||||
|
||||
// Commission settings and the standing deductions. The running totals the batch maintains -
|
||||
// `commission` and `tdsAmount` - are deliberately not editable here: they are the outcome of
|
||||
// posted runs, and typing over them would put the agent's balance out of step with the runs
|
||||
// that produced it.
|
||||
if (command.isChangeInBooleanParameterNamed("commissionCalculation", this.commissionCalculation)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("commissionCalculation");
|
||||
actualChanges.put("commissionCalculation", newValue);
|
||||
this.commissionCalculation = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBooleanParameterNamed("tdsApplicable", this.tdsApplicable)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("tdsApplicable");
|
||||
@@ -364,77 +497,27 @@ public class Agent extends AbstractPersistableCustom<Long> {
|
||||
this.tdsApplicable = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBigDecimalParameterNamed("tdsPercent", this.tdsPercent)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("tdsPercent");
|
||||
actualChanges.put("tdsPercent", newValue);
|
||||
this.tdsPercent = newValue;
|
||||
}
|
||||
decimalChange(command, actualChanges, "tdsPercent", this.tdsPercent, v -> this.tdsPercent = v);
|
||||
decimalChange(command, actualChanges, "deductCommission", this.deductCommission, v -> this.deductCommission = v);
|
||||
decimalChange(command, actualChanges, "agentDeposit", this.agentDeposit, v -> this.agentDeposit = v);
|
||||
decimalChange(command, actualChanges, "festivalAdvance", this.festivalAdvance, v -> this.festivalAdvance = v);
|
||||
decimalChange(command, actualChanges, "otherDeduction", this.otherDeduction, v -> this.otherDeduction = v);
|
||||
|
||||
stringChange(command, actualChanges, "transferBranch", this.transferBranch,
|
||||
v -> this.transferBranch = StringUtils.defaultIfEmpty(v, null));
|
||||
stringChange(command, actualChanges, "transferAmountAccountType", this.transferAmountAccountType,
|
||||
v -> this.transferAmountAccountType = StringUtils.defaultIfEmpty(v, null));
|
||||
stringChange(command, actualChanges, "transferAccountNo", this.transferAccountNo,
|
||||
v -> this.transferAccountNo = StringUtils.defaultIfEmpty(v, null));
|
||||
|
||||
if (command.isChangeInBooleanParameterNamed("commissionCalculation", this.commissionCalculation)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("commissionCalculation");
|
||||
actualChanges.put("commissionCalculation", newValue);
|
||||
this.commissionCalculation = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBigDecimalParameterNamed("limit", this.limitAmount)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("limit");
|
||||
actualChanges.put("limit", newValue);
|
||||
this.limitAmount = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBigDecimalParameterNamed("machineRent", this.machineRent)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("machineRent");
|
||||
actualChanges.put("machineRent", newValue);
|
||||
this.machineRent = newValue;
|
||||
}
|
||||
|
||||
stringChange(command, actualChanges, "agentSecurityBranch", this.agentSecurityBranch,
|
||||
v -> this.agentSecurityBranch = StringUtils.defaultIfEmpty(v, null));
|
||||
stringChange(command, actualChanges, "agentSecurityAccountType", this.agentSecurityAccountType,
|
||||
v -> this.agentSecurityAccountType = StringUtils.defaultIfEmpty(v, null));
|
||||
stringChange(command, actualChanges, "agentSecurityAccountNo", this.agentSecurityAccountNo,
|
||||
v -> this.agentSecurityAccountNo = StringUtils.defaultIfEmpty(v, null));
|
||||
|
||||
if (command.isChangeInBigDecimalParameterNamed("otherCommission", this.otherCommission)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("otherCommission");
|
||||
actualChanges.put("otherCommission", newValue);
|
||||
this.otherCommission = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBigDecimalParameterNamed("agentDeposit", this.agentDeposit)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("agentDeposit");
|
||||
actualChanges.put("agentDeposit", newValue);
|
||||
this.agentDeposit = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBigDecimalParameterNamed("festivalAdvance", this.festivalAdvance)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("festivalAdvance");
|
||||
actualChanges.put("festivalAdvance", newValue);
|
||||
this.festivalAdvance = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBigDecimalParameterNamed("otherDeduction", this.otherDeduction)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("otherDeduction");
|
||||
actualChanges.put("otherDeduction", newValue);
|
||||
this.otherDeduction = newValue;
|
||||
// A rate means nothing while TDS is switched off, so it is not left behind to be applied by a
|
||||
// later switch-on nobody connected to it.
|
||||
if (!this.tdsApplicable) {
|
||||
this.tdsPercent = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
return actualChanges;
|
||||
}
|
||||
|
||||
private void stringChange(final JsonCommand command, final Map<String, Object> actualChanges, final String paramName,
|
||||
final String existingValue, final java.util.function.Consumer<String> setter) {
|
||||
if (command.isChangeInStringParameterNamed(paramName, existingValue)) {
|
||||
final String newValue = command.stringValueOfParameterNamed(paramName);
|
||||
private void decimalChange(final JsonCommand command, final Map<String, Object> actualChanges, final String paramName,
|
||||
final BigDecimal existingValue, final Consumer<BigDecimal> setter) {
|
||||
if (command.isChangeInBigDecimalParameterNamed(paramName, existingValue)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(paramName);
|
||||
actualChanges.put(paramName, newValue);
|
||||
setter.accept(newValue);
|
||||
setter.accept(newValue == null ? BigDecimal.ZERO : newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -25,17 +25,21 @@ import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
|
||||
/**
|
||||
* A single guarantor recorded against an {@link Agent} (or, reused with the same shape, against a
|
||||
* SubAgent). Always created/replaced as part of its parent agent's own create/update command - it has
|
||||
* no independent API of its own.
|
||||
*
|
||||
* Audited in its own right even so: a guarantor is who the bank goes to if an agent's collections go
|
||||
* missing, and a timestamp on the agent saying something changed does not say that the person
|
||||
* standing behind them was swapped, nor by whom.
|
||||
*/
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "m_dds_agent_guarantor")
|
||||
public class AgentGuarantor extends AbstractPersistableCustom<Long> {
|
||||
public class AgentGuarantor extends AbstractAuditableCustom {
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "agent_id", nullable = false)
|
||||
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||
|
||||
/**
|
||||
* One row of an Agent's time-bound collection-limit history (From Date / To Date / Limit) - an
|
||||
* Agent's authorized collection limit can change over time and every change is kept, not overwritten.
|
||||
*/
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "m_dds_agent_limit")
|
||||
public class AgentLimit extends AbstractPersistableCustom<Long> {
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "agent_id", nullable = false)
|
||||
private Agent agent;
|
||||
|
||||
@Column(name = "from_date", nullable = false)
|
||||
private LocalDate fromDate;
|
||||
|
||||
@Column(name = "to_date", nullable = false)
|
||||
private LocalDate toDate;
|
||||
|
||||
@Column(name = "limit_amount", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal limitAmount;
|
||||
|
||||
protected AgentLimit() {
|
||||
//
|
||||
}
|
||||
|
||||
public AgentLimit(final LocalDate fromDate, final LocalDate toDate, final BigDecimal limitAmount) {
|
||||
this.fromDate = fromDate;
|
||||
this.toDate = toDate;
|
||||
this.limitAmount = limitAmount;
|
||||
}
|
||||
|
||||
void setAgent(final Agent agent) {
|
||||
this.agent = agent;
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when an agent is closed while their books are not square.
|
||||
*
|
||||
* Closing is the end of the relationship, so it is refused until nothing is left hanging: no accounts
|
||||
* still being collected from, no cash held that has not reached a branch, and no commission earned but
|
||||
* unpaid. Each of those is money or a customer that would be left with no one responsible for it.
|
||||
*
|
||||
* Deactivating is deliberately not guarded this way. Switching an agent off has to be possible the
|
||||
* moment the bank decides it - if it waited for the books to be squared, an agent the bank wanted
|
||||
* stopped would keep collecting while the paperwork caught up.
|
||||
*/
|
||||
public class AgentCannotBeClosedException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public AgentCannotBeClosedException(final String agentCode, final String reason) {
|
||||
super("error.msg.dds.agent.cannot.be.closed", "Agent " + agentCode + " cannot be closed: " + reason, agentCode, reason);
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when an agent is listed as one of its own guarantors - a guarantee is only worth something
|
||||
* coming from someone other than the agent it backs.
|
||||
*/
|
||||
public class AgentCannotGuaranteeItselfException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public AgentCannotGuaranteeItselfException(final Long clientId) {
|
||||
super("error.msg.agent.cannot.guarantee.itself", "An agent cannot be its own guarantor", clientId);
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a commission run cannot pay an agent because their account will not take the credit.
|
||||
*
|
||||
* The account is checked when it is chosen, so reaching this means something changed afterwards -
|
||||
* most likely the account was closed. Failing here rolls the whole run back rather than paying the
|
||||
* other agents and leaving this one silently unpaid: a commission run that has half happened is
|
||||
* worse to unpick than one that has not happened at all.
|
||||
*/
|
||||
public class AgentCommissionAccountUnusableException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public AgentCommissionAccountUnusableException(final String agentCode, final String reason) {
|
||||
super("error.msg.dds.agent.commission.account.unusable",
|
||||
"Commission for agent " + agentCode + " could not be paid: " + reason + ".", agentCode, reason);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when the ledger account named for an agent could not carry that agent's cash.
|
||||
*
|
||||
* Refused when it is chosen rather than when it is first posted to. An account that cannot take the
|
||||
* entry fails the whole collection, at the counter, with a customer waiting - and the person who
|
||||
* picked it is not the person who finds out.
|
||||
*/
|
||||
public class AgentGlAccountUnusableException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public AgentGlAccountUnusableException(final String glCode, final String reason) {
|
||||
super("error.msg.dds.agent.gl.account.unusable",
|
||||
"Ledger account " + glCode + " cannot be used for this agent: " + reason
|
||||
+ ". Choose a detail asset account that no other agent already holds.",
|
||||
glCode, reason);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when an agent is given a savings account that belongs to somebody else.
|
||||
*
|
||||
* An agent's commission is their own earnings, so it is paid into their own account. Accepting
|
||||
* another person's account number here would route one person's pay into another's balance - a
|
||||
* mistake that reads as ordinary data entry and would only surface when the person who should have
|
||||
* been paid asked where their money went.
|
||||
*/
|
||||
public class AgentSavingsAccountClientMismatchException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public AgentSavingsAccountClientMismatchException(final String accountNo) {
|
||||
super("error.msg.dds.agent.savings.account.client.mismatch",
|
||||
"Savings account " + accountNo + " belongs to a different client. An agent's commission is paid into their own account.",
|
||||
accountNo);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when an agent is given a savings account number that no open account carries.
|
||||
*
|
||||
* The agent's commission is paid into this account, so a number that exists only as typed text means
|
||||
* a payment with nowhere to land - and nothing downstream would catch it, because the commission run
|
||||
* records what it paid, not whether the account it named was real.
|
||||
*/
|
||||
public class AgentSavingsAccountNotFoundException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public AgentSavingsAccountNotFoundException(final String accountNo) {
|
||||
super("error.msg.dds.agent.savings.account.not.found",
|
||||
"No open savings account has the number " + accountNo + ". Pick one of the client's own accounts.", accountNo);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when an agent's commission account is a fixed or recurring deposit rather than a savings
|
||||
* account.
|
||||
*
|
||||
* Commission is paid in as a credit whenever a commission run posts. A savings account takes credits
|
||||
* at any time; a fixed deposit is a single sum placed for a term, and a recurring deposit takes only
|
||||
* its own scheduled instalments - so paying into either is not a thing a bank does, and the posting
|
||||
* would fail when the run came round. Refusing it as the account is chosen puts the error in front
|
||||
* of the officer who can fix it, rather than in a batch log weeks later.
|
||||
*/
|
||||
public class AgentSavingsAccountNotOperativeException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public AgentSavingsAccountNotOperativeException(final String accountNo, final String accountKind) {
|
||||
super("error.msg.dds.agent.savings.account.not.operative",
|
||||
"Account " + accountNo + " is a " + accountKind + " account. Commission has to be paid into an ordinary savings "
|
||||
+ "account, so pick one of the client's own - not a deposit and not a DDS scheme account.",
|
||||
accountNo, accountKind);
|
||||
}
|
||||
}
|
||||
+41
-4
@@ -22,19 +22,25 @@ import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
|
||||
/**
|
||||
* One row of the agent commission chart: an ACCOUNT slab (commission percentage on collection
|
||||
* volume) or a COMMISSION slab (the override a senior agent earns on a sub-agent's commission).
|
||||
* Append-only reference data, like the legacy screen it replaces - there is deliberately no update or
|
||||
* delete API; a superseded slab range is retired by adding a new row, not editing history.
|
||||
* Reference data, not history: a slab says what an agent earns on a given collection volume, and the
|
||||
* commission run reads whatever the chart says at the time it runs. A mistyped range or percentage
|
||||
* therefore has to be correctable - the legacy screen offered an edit on every saved row - so a slab
|
||||
* can be updated. It still cannot be deleted: removing a range silently pays nothing to any agent who
|
||||
* lands in it, which reads as a system fault rather than a decision.
|
||||
*/
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "m_dds_commission_slab")
|
||||
public class CommissionSlab extends AbstractPersistableCustom<Long> {
|
||||
public class CommissionSlab extends AbstractAuditableCustom {
|
||||
|
||||
@Column(name = "slab_type", length = 20, nullable = false)
|
||||
private String slabType;
|
||||
@@ -58,4 +64,35 @@ public class CommissionSlab extends AbstractPersistableCustom<Long> {
|
||||
this.slabTo = slabTo;
|
||||
this.commissionPercent = commissionPercent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Corrects this slab, reporting only what actually changed so the audit trail records the
|
||||
* correction rather than a wholesale rewrite.
|
||||
*/
|
||||
public Map<String, Object> update(final JsonCommand command) {
|
||||
final Map<String, Object> actualChanges = new LinkedHashMap<>(4);
|
||||
|
||||
if (command.isChangeInStringParameterNamed("slabType", this.slabType)) {
|
||||
final String newValue = command.stringValueOfParameterNamed("slabType");
|
||||
actualChanges.put("slabType", newValue);
|
||||
this.slabType = newValue;
|
||||
}
|
||||
if (command.isChangeInBigDecimalParameterNamed("slabFrom", this.slabFrom)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("slabFrom");
|
||||
actualChanges.put("slabFrom", newValue);
|
||||
this.slabFrom = newValue;
|
||||
}
|
||||
if (command.isChangeInBigDecimalParameterNamed("slabTo", this.slabTo)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("slabTo");
|
||||
actualChanges.put("slabTo", newValue);
|
||||
this.slabTo = newValue;
|
||||
}
|
||||
if (command.isChangeInBigDecimalParameterNamed("commissionPercent", this.commissionPercent)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("commissionPercent");
|
||||
actualChanges.put("commissionPercent", newValue);
|
||||
this.commissionPercent = newValue;
|
||||
}
|
||||
|
||||
return actualChanges;
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.organisation.commissionslab.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||
|
||||
/**
|
||||
* Thrown when a commission slab being corrected no longer exists.
|
||||
*/
|
||||
public class CommissionSlabNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||
|
||||
public CommissionSlabNotFoundException(final Long slabId) {
|
||||
super("error.msg.commissionslab.id.invalid", "Commission slab with identifier " + slabId + " does not exist", slabId);
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.organisation.commissionslab.exception;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a commission slab's range overlaps one that already covers part of it.
|
||||
*
|
||||
* The commission run picks the first slab whose range contains the agent's collection volume, so
|
||||
* two slabs covering the same figure make the rate that gets applied a matter of which row the
|
||||
* database returns first. The same agent could be paid a different percentage from one month to the
|
||||
* next with nothing in the data to explain it, which is why this is refused rather than warned
|
||||
* about.
|
||||
*/
|
||||
public class OverlappingCommissionSlabException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public OverlappingCommissionSlabException(final String slabType, final BigDecimal from, final BigDecimal to,
|
||||
final BigDecimal existingFrom, final BigDecimal existingTo) {
|
||||
super("error.msg.commissionslab.overlapping",
|
||||
slabType + " slab " + from.toPlainString() + " to " + to.toPlainString() + " overlaps the existing slab "
|
||||
+ existingFrom.toPlainString() + " to " + existingTo.toPlainString(),
|
||||
slabType, from, to, existingFrom, existingTo);
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Immutable data object representing a DDS Account Type (the legacy Account Type Creation record).
|
||||
*/
|
||||
@Getter
|
||||
@Builder(toBuilder = true)
|
||||
public final class DdsAccountTypeData implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final Long id;
|
||||
private final String accountTypeGroup;
|
||||
private final String accountTypeCode;
|
||||
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;
|
||||
/**
|
||||
* What this scheme charges for closing an account before its term is up: how long it may not be
|
||||
* closed at all, what comes off the customer's rate if they bear the cost, and what comes off the
|
||||
* agent's commission either way. Read at the moment of closure, so a change on the scheme takes
|
||||
* effect on accounts already open.
|
||||
*/
|
||||
private final Integer prematureLockinMonths;
|
||||
private final BigDecimal prematureInterestReduction;
|
||||
private final BigDecimal prematureAgentCommissionPenalty;
|
||||
private final Boolean active;
|
||||
private final LocalDateTime createdDate;
|
||||
private final LocalDateTime lastModifiedDate;
|
||||
}
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import lombok.Getter;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
|
||||
/**
|
||||
* A DDS scheme variant, as registered on the legacy "Account Type Creation" screen: a group (e.g.
|
||||
* "DDS"), a short code (e.g. "DS"), a description (e.g. "DAILY DEPOSIT"), and whether interest is
|
||||
* posted automatically and how often.
|
||||
*
|
||||
* This is master/reference data, not a deposit product - a Party picks one of these by code in its
|
||||
* "Sub Type" field. Nothing here drives interest calculation: the DDS batch job accrues interest
|
||||
* straight off each Party's own rate and balance.
|
||||
*/
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "m_dds_account_type", uniqueConstraints = {
|
||||
@UniqueConstraint(columnNames = { "account_type_code" }, name = "uq_dds_account_type_code") })
|
||||
public class DdsAccountType extends AbstractAuditableCustom {
|
||||
|
||||
@Column(name = "account_type_group", length = 60, nullable = false)
|
||||
private String accountTypeGroup;
|
||||
|
||||
@Column(name = "account_type_code", length = 20, nullable = false)
|
||||
private String accountTypeCode;
|
||||
|
||||
@Column(name = "account_type_description", length = 200, nullable = false)
|
||||
private String accountTypeDescription;
|
||||
|
||||
@Column(name = "auto_interest_post", nullable = false)
|
||||
private boolean autoInterestPost;
|
||||
|
||||
@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.
|
||||
*
|
||||
* Kept on the scheme rather than switched on bank-wide, for the same reason interest posting is:
|
||||
* a bank runs several schemes at once and will not want them all behaving the same way. It says
|
||||
* only whether the closing should happen, never when - the batch run decides that, exactly as it
|
||||
* does for {@code autoInterestPost}.
|
||||
*
|
||||
* A scheme with this set still only closes an account that has somewhere for the money to go. If
|
||||
* the account names no savings account to be paid into, nothing happens automatically and the
|
||||
* clerk pays it out as before: money is never sent somewhere nobody chose.
|
||||
*/
|
||||
@Column(name = "maturity_auto_close", nullable = false)
|
||||
private boolean maturityAutoClose;
|
||||
|
||||
/**
|
||||
* How long an account on this scheme must run before it may be closed early at all. Below this
|
||||
* the request is refused outright rather than charged for.
|
||||
*
|
||||
* Per-scheme rather than one bank-wide figure: a lock-in longer than the scheme's own term would
|
||||
* make early closure unreachable, so a two-month scheme and a five-year one cannot share one.
|
||||
*/
|
||||
@Column(name = "premature_lockin_months", nullable = false)
|
||||
private Integer prematureLockinMonths;
|
||||
|
||||
/**
|
||||
* Percentage points off the customer's interest rate when the customer bears the cost of closing
|
||||
* early - a 2% scheme pays 1% over the whole period, not only the part left to run.
|
||||
*/
|
||||
@Column(name = "premature_interest_reduction", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal prematureInterestReduction;
|
||||
|
||||
/**
|
||||
* Percentage off the collecting agent's commission on an early closure, applied whichever way the
|
||||
* shortfall is met - an account closed early is a round the agent did not keep going.
|
||||
*/
|
||||
@Column(name = "premature_agent_commission_penalty", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal prematureAgentCommissionPenalty;
|
||||
|
||||
/**
|
||||
* An account type is retired by deactivating it, never by deleting: Parties are opened against
|
||||
* it, so removing the row would orphan every account already using it. Inactive types stay out
|
||||
* of the pick lists for new accounts but leave existing accounts untouched.
|
||||
*/
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private boolean active;
|
||||
|
||||
protected DdsAccountType() {
|
||||
//
|
||||
}
|
||||
|
||||
private DdsAccountType(final JsonCommand command) {
|
||||
this.accountTypeGroup = command.stringValueOfParameterNamed("accountTypeGroup");
|
||||
this.accountTypeCode = normaliseCode(command.stringValueOfParameterNamed("accountTypeCode"));
|
||||
this.accountTypeDescription = command.stringValueOfParameterNamed("accountTypeDescription");
|
||||
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);
|
||||
// A newly created account type is available for use straight away.
|
||||
this.active = !command.parameterExists("active") || command.booleanPrimitiveValueOfParameterNamed("active");
|
||||
}
|
||||
|
||||
/**
|
||||
* The bank's stated terms, used when a scheme is created without saying otherwise. They are
|
||||
* defaults rather than fixed rules - the screen offers them and the bank changes them per scheme.
|
||||
*/
|
||||
private static final int DEFAULT_LOCKIN_MONTHS = 12;
|
||||
private static final BigDecimal DEFAULT_INTEREST_REDUCTION = BigDecimal.ONE;
|
||||
private static final BigDecimal DEFAULT_AGENT_PENALTY = BigDecimal.ONE;
|
||||
|
||||
private static Integer valueOr(final JsonCommand command, final String parameter, final int fallback) {
|
||||
final Integer supplied = command.parameterExists(parameter)
|
||||
? command.integerValueOfParameterNamedDefaultToNullIfZero(parameter)
|
||||
: null;
|
||||
return supplied == null ? fallback : supplied;
|
||||
}
|
||||
|
||||
private static BigDecimal valueOr(final JsonCommand command, final String parameter, final BigDecimal fallback) {
|
||||
// Deliberately not "default to null if zero": zero is a real answer here - a scheme that
|
||||
// charges the customer nothing for closing early - and must not be read as "unset".
|
||||
final BigDecimal supplied = command.parameterExists(parameter)
|
||||
? command.bigDecimalValueOfParameterNamed(parameter)
|
||||
: null;
|
||||
return supplied == null ? fallback : supplied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codes are the account type's identity and are matched against elsewhere, so "ds" and "DS" must
|
||||
* not become two different types - they are stored upper-cased and trimmed.
|
||||
*/
|
||||
private static String normaliseCode(final String code) {
|
||||
return code == null ? null : code.trim().toUpperCase();
|
||||
}
|
||||
|
||||
public static DdsAccountType fromJson(final JsonCommand command) {
|
||||
return new DdsAccountType(command);
|
||||
}
|
||||
|
||||
public Map<String, Object> update(final JsonCommand command) {
|
||||
|
||||
final Map<String, Object> actualChanges = new LinkedHashMap<>(6);
|
||||
|
||||
if (command.isChangeInStringParameterNamed("accountTypeGroup", this.accountTypeGroup)) {
|
||||
final String newValue = command.stringValueOfParameterNamed("accountTypeGroup");
|
||||
actualChanges.put("accountTypeGroup", newValue);
|
||||
this.accountTypeGroup = newValue;
|
||||
}
|
||||
|
||||
final String incomingCode = normaliseCode(command.stringValueOfParameterNamedAllowingNull("accountTypeCode"));
|
||||
if (incomingCode != null && !incomingCode.equals(this.accountTypeCode)) {
|
||||
actualChanges.put("accountTypeCode", incomingCode);
|
||||
this.accountTypeCode = incomingCode;
|
||||
}
|
||||
|
||||
if (command.isChangeInStringParameterNamed("accountTypeDescription", this.accountTypeDescription)) {
|
||||
final String newValue = command.stringValueOfParameterNamed("accountTypeDescription");
|
||||
actualChanges.put("accountTypeDescription", newValue);
|
||||
this.accountTypeDescription = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBooleanParameterNamed("autoInterestPost", this.autoInterestPost)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("autoInterestPost");
|
||||
actualChanges.put("autoInterestPost", newValue);
|
||||
this.autoInterestPost = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInStringParameterNamed("autoInterestPostWhen", this.autoInterestPostWhen)) {
|
||||
final String newValue = command.stringValueOfParameterNamed("autoInterestPostWhen");
|
||||
actualChanges.put("autoInterestPostWhen", newValue);
|
||||
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);
|
||||
// Zero lock-in is meaningful - it lets an account be closed early from day one - but the
|
||||
// column cannot hold null, so it is kept as zero rather than reset to the default.
|
||||
this.prematureLockinMonths = newValue == null ? 0 : newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBigDecimalParameterNamed("prematureInterestReduction", this.prematureInterestReduction)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("prematureInterestReduction");
|
||||
actualChanges.put("prematureInterestReduction", newValue);
|
||||
this.prematureInterestReduction = newValue == null ? BigDecimal.ZERO : newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBigDecimalParameterNamed("prematureAgentCommissionPenalty", this.prematureAgentCommissionPenalty)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("prematureAgentCommissionPenalty");
|
||||
actualChanges.put("prematureAgentCommissionPenalty", newValue);
|
||||
this.prematureAgentCommissionPenalty = newValue == null ? BigDecimal.ZERO : newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBooleanParameterNamed("active", this.active)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("active");
|
||||
actualChanges.put("active", newValue);
|
||||
this.active = newValue;
|
||||
}
|
||||
|
||||
return actualChanges;
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
|
||||
/**
|
||||
* A {@link RuntimeException} thrown when a DDS account type resource is not found.
|
||||
*/
|
||||
public class DdsAccountTypeNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||
|
||||
public DdsAccountTypeNotFoundException(final Long id) {
|
||||
super("error.msg.dds.account.type.id.invalid", "DDS account type with identifier " + id + " does not exist", id);
|
||||
}
|
||||
|
||||
public DdsAccountTypeNotFoundException(final Long id, final EmptyResultDataAccessException e) {
|
||||
super("error.msg.dds.account.type.id.invalid", "DDS account type with identifier " + id + " does not exist", id, e);
|
||||
}
|
||||
}
|
||||
+8
@@ -36,6 +36,10 @@ public final class BatchRunData implements Serializable {
|
||||
private final Long id;
|
||||
private final String jobType;
|
||||
private final String branchScope;
|
||||
|
||||
/** Which DDS scheme the run covered, or null for every scheme. Set by the automatic posting job. */
|
||||
private final String subType;
|
||||
|
||||
private final LocalDate jobDate;
|
||||
private final Long agentFrom;
|
||||
private final Long agentTo;
|
||||
@@ -43,6 +47,10 @@ public final class BatchRunData implements Serializable {
|
||||
private final Long partyTo;
|
||||
private final String status;
|
||||
private final Integer totalRows;
|
||||
/** How many rows a crediting run has applied. Equal to totalRows once it has finished. */
|
||||
private final Integer rowsPosted;
|
||||
/** Why a run stopped, when it did - a crediting run happens in the background and has no other voice. */
|
||||
private final String failureReason;
|
||||
private final BigDecimal totalAmount;
|
||||
private final LocalDate generatedOnDate;
|
||||
private final LocalDate postedOnDate;
|
||||
|
||||
+16
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.dds.batch.data;
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@@ -37,5 +38,20 @@ public final class BatchRunRowData implements Serializable {
|
||||
private final String partyName;
|
||||
private final Long agentId;
|
||||
private final String agentCode;
|
||||
private final String agentName;
|
||||
|
||||
/** What the row applies: interest, or commission net of every deduction. */
|
||||
private final BigDecimal amount;
|
||||
|
||||
/**
|
||||
* How that amount was arrived at. Null on rows where it does not apply - a posting row moves a
|
||||
* figure an earlier calculation run worked out, so the rate and base belong to that run.
|
||||
*/
|
||||
private final BigDecimal baseAmount;
|
||||
private final BigDecimal ratePercent;
|
||||
private final BigDecimal grossAmount;
|
||||
private final BigDecimal tdsAmount;
|
||||
private final BigDecimal deductionAmount;
|
||||
private final LocalDate fromDate;
|
||||
private final LocalDate toDate;
|
||||
}
|
||||
|
||||
+62
-4
@@ -30,7 +30,7 @@ import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
|
||||
/**
|
||||
* One Calculate-then-Post batch run - the deliberate two-step control from the legacy Interest Part
|
||||
@@ -43,7 +43,7 @@ import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "m_dds_batch_run")
|
||||
public class BatchRun extends AbstractPersistableCustom<Long> {
|
||||
public class BatchRun extends AbstractAuditableCustom {
|
||||
|
||||
@Column(name = "job_type", length = 40, nullable = false)
|
||||
private String jobType;
|
||||
@@ -51,6 +51,17 @@ public class BatchRun extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "branch_scope", length = 20, nullable = false)
|
||||
private String branchScope;
|
||||
|
||||
/**
|
||||
* Which DDS scheme this run covered, or null for every scheme.
|
||||
*
|
||||
* A posting day belongs to an account type rather than to the bank, so a run started by the
|
||||
* scheduled job covers one scheme at a time - otherwise the scheme due on the 15th would drag the
|
||||
* month-end scheme's accounts along with it. A run started by hand from the screen leaves this
|
||||
* null and covers everything, as it always did.
|
||||
*/
|
||||
@Column(name = "sub_type", length = 60)
|
||||
private String subType;
|
||||
|
||||
@Column(name = "job_date", nullable = false)
|
||||
private LocalDate jobDate;
|
||||
|
||||
@@ -72,6 +83,14 @@ public class BatchRun extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "total_rows", nullable = false)
|
||||
private Integer totalRows;
|
||||
|
||||
/** How many of those rows have been credited so far - what a screen reads to show progress. */
|
||||
@Column(name = "rows_posted", nullable = false)
|
||||
private Integer rowsPosted = 0;
|
||||
|
||||
/** 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;
|
||||
|
||||
@Column(name = "total_amount", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal totalAmount;
|
||||
|
||||
@@ -91,8 +110,14 @@ public class BatchRun extends AbstractPersistableCustom<Long> {
|
||||
|
||||
public BatchRun(final String jobType, final String branchScope, final LocalDate jobDate, final Long agentFrom,
|
||||
final Long agentTo, final Long partyFrom, final Long partyTo, final LocalDate generatedOnDate) {
|
||||
this(jobType, branchScope, jobDate, agentFrom, agentTo, partyFrom, partyTo, generatedOnDate, null);
|
||||
}
|
||||
|
||||
public BatchRun(final String jobType, final String branchScope, final LocalDate jobDate, final Long agentFrom,
|
||||
final Long agentTo, final Long partyFrom, final Long partyTo, final LocalDate generatedOnDate, final String subType) {
|
||||
this.jobType = jobType;
|
||||
this.branchScope = branchScope;
|
||||
this.subType = subType;
|
||||
this.jobDate = jobDate;
|
||||
this.agentFrom = agentFrom;
|
||||
this.agentTo = agentTo;
|
||||
@@ -112,11 +137,44 @@ public class BatchRun extends AbstractPersistableCustom<Long> {
|
||||
}
|
||||
|
||||
public boolean isPosted() {
|
||||
return "POSTED".equals(this.status);
|
||||
return STATUS_POSTED.equals(this.status);
|
||||
}
|
||||
|
||||
/** Under way. Set before the work starts, so a second press finds it already running. */
|
||||
public boolean isPosting() {
|
||||
return STATUS_POSTING.equals(this.status);
|
||||
}
|
||||
|
||||
public void markPosting() {
|
||||
this.status = STATUS_POSTING;
|
||||
this.rowsPosted = 0;
|
||||
this.failureReason = null;
|
||||
}
|
||||
|
||||
public void recordProgress(final int posted) {
|
||||
this.rowsPosted = posted;
|
||||
}
|
||||
|
||||
public void markPosted(final LocalDate postedOnDate) {
|
||||
this.status = "POSTED";
|
||||
this.status = STATUS_POSTED;
|
||||
this.postedOnDate = postedOnDate;
|
||||
this.rowsPosted = this.totalRows;
|
||||
this.failureReason = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records why a run stopped, and leaves it able to be run again.
|
||||
*
|
||||
* Nothing it had credited stands: the whole run is one transaction, so a failure takes all of it
|
||||
* back. That is the point - the branch is left with a run that plainly did not happen rather than
|
||||
* one it has to reconcile by hand.
|
||||
*/
|
||||
public void markFailed(final String reason) {
|
||||
this.status = STATUS_FAILED;
|
||||
this.failureReason = reason == null ? "Unknown error" : reason.substring(0, Math.min(reason.length(), 500));
|
||||
}
|
||||
|
||||
private static final String STATUS_POSTED = "POSTED";
|
||||
private static final String STATUS_POSTING = "POSTING";
|
||||
private static final String STATUS_FAILED = "FAILED";
|
||||
}
|
||||
|
||||
+68
-3
@@ -24,12 +24,19 @@ import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||
|
||||
/**
|
||||
* One previewed/posted line of a {@link BatchRun}: for an interest job, one Party's accrued interest;
|
||||
* for a commission job, one Agent's commission on their collected volume (partyId is null there).
|
||||
* One previewed/posted line of a {@link BatchRun}: for an interest job, one Party's interest; for a
|
||||
* commission job, one Agent's commission on their collected volume (partyId is null there).
|
||||
*
|
||||
* {@code amount} is always what actually gets applied. The remaining figures record how that amount
|
||||
* was arrived at, which is what the Agent Commission Statement prints and what an agent querying a
|
||||
* payout needs to see. They are stored rather than recomputed on demand because the inputs move: the
|
||||
* slab chart can be corrected and the standing deductions change, so a later recomputation would not
|
||||
* reproduce the figure that was actually paid.
|
||||
*/
|
||||
@Getter
|
||||
@Entity
|
||||
@@ -46,17 +53,75 @@ public class BatchRunRow extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "agent_id", nullable = false)
|
||||
private Long agentId;
|
||||
|
||||
/** What this row applies: interest accrued or posted, or commission net of every deduction. */
|
||||
@Column(name = "amount", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal amount;
|
||||
|
||||
/**
|
||||
* What the rate was applied to: the collection volume for commission, or the daily product
|
||||
* (balance summed over the days it was held) for interest.
|
||||
*/
|
||||
@Column(name = "base_amount", scale = 6, precision = 19)
|
||||
private BigDecimal baseAmount;
|
||||
|
||||
@Column(name = "rate_percent", scale = 6, precision = 19)
|
||||
private BigDecimal ratePercent;
|
||||
|
||||
/** Before deductions. Equal to {@code amount} on rows that have none. */
|
||||
@Column(name = "gross_amount", scale = 6, precision = 19)
|
||||
private BigDecimal grossAmount;
|
||||
|
||||
@Column(name = "tds_amount", scale = 6, precision = 19)
|
||||
private BigDecimal tdsAmount;
|
||||
|
||||
@Column(name = "deduction_amount", scale = 6, precision = 19)
|
||||
private BigDecimal deductionAmount;
|
||||
|
||||
@Column(name = "from_date")
|
||||
private LocalDate fromDate;
|
||||
|
||||
@Column(name = "to_date")
|
||||
private LocalDate toDate;
|
||||
|
||||
protected BatchRunRow() {
|
||||
//
|
||||
}
|
||||
|
||||
public BatchRunRow(final Long partyId, final Long agentId, final BigDecimal amount) {
|
||||
private BatchRunRow(final Long partyId, final Long agentId, final BigDecimal amount, final BigDecimal baseAmount,
|
||||
final BigDecimal ratePercent, final BigDecimal grossAmount, final BigDecimal tdsAmount, final BigDecimal deductionAmount,
|
||||
final LocalDate fromDate, final LocalDate toDate) {
|
||||
this.partyId = partyId;
|
||||
this.agentId = agentId;
|
||||
this.amount = amount;
|
||||
this.baseAmount = baseAmount;
|
||||
this.ratePercent = ratePercent;
|
||||
this.grossAmount = grossAmount;
|
||||
this.tdsAmount = tdsAmount;
|
||||
this.deductionAmount = deductionAmount;
|
||||
this.fromDate = fromDate;
|
||||
this.toDate = toDate;
|
||||
}
|
||||
|
||||
/** Interest earned on a Party's balance over the days it was held. Nothing is deducted from it. */
|
||||
public static BatchRunRow forInterest(final Long partyId, final Long agentId, final BigDecimal amount,
|
||||
final BigDecimal dailyProduct, final BigDecimal ratePercent, final LocalDate fromDate, final LocalDate toDate) {
|
||||
return new BatchRunRow(partyId, agentId, amount, dailyProduct, ratePercent, amount, null, null, fromDate, toDate);
|
||||
}
|
||||
|
||||
/** An Agent's commission on their collection volume, net of TDS and the standing deductions. */
|
||||
public static BatchRunRow forCommission(final Long agentId, final BigDecimal netAmount, final BigDecimal collectionVolume,
|
||||
final BigDecimal ratePercent, final BigDecimal grossAmount, final BigDecimal tdsAmount, final BigDecimal deductionAmount,
|
||||
final LocalDate fromDate, final LocalDate toDate) {
|
||||
return new BatchRunRow(null, agentId, netAmount, collectionVolume, ratePercent, grossAmount, tdsAmount, deductionAmount,
|
||||
fromDate, toDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* A posting row: it moves an amount that a prior calculation run already worked out, so there is
|
||||
* no rate or base to record against it - the run that calculated it holds those.
|
||||
*/
|
||||
public static BatchRunRow forPosting(final Long partyId, final Long agentId, final BigDecimal amount, final LocalDate asOnDate) {
|
||||
return new BatchRunRow(partyId, agentId, amount, null, null, amount, null, null, asOnDate, asOnDate);
|
||||
}
|
||||
|
||||
void setBatchRun(final BatchRun batchRun) {
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.batch.exception;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when an agent's collection volume falls in a gap in the commission chart, so there is no rate
|
||||
* to pay them at.
|
||||
*
|
||||
* The run stops rather than skipping the agent. A gap is a mistake in the chart, and an agent who
|
||||
* worked all month reading as earning nothing looks exactly like an agent who collected nothing - the
|
||||
* one failure nobody would query until the agent complained. Naming the agent and the amount that
|
||||
* found no slab points straight at the range that needs adding.
|
||||
*/
|
||||
public class NoCommissionSlabException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public NoCommissionSlabException(final String agentCode, final BigDecimal collectionVolume) {
|
||||
super("error.msg.ddsbatchrun.no.commission.slab",
|
||||
"No commission slab covers " + collectionVolume.toPlainString() + ", the collection volume for agent " + agentCode
|
||||
+ ". Add a slab covering this amount to the Commission Slab Master and run the calculation again.",
|
||||
agentCode, collectionVolume);
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.calculation;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
|
||||
/**
|
||||
* What a collection agent has earned on a period's collections, and what is left after deductions.
|
||||
*
|
||||
* <pre>
|
||||
* gross the slab's percentage of everything collected
|
||||
* tds withheld from the gross, when the agent is liable for it
|
||||
* deductions the four standing amounts the bank recovers from them
|
||||
* net what the agent is actually paid
|
||||
* </pre>
|
||||
*
|
||||
* Every figure is kept, not just the net: an agent asking why their commission was short needs the
|
||||
* whole breakdown, and a later change to the slab must not alter what was already paid.
|
||||
*
|
||||
* <p>
|
||||
* Kept apart from the job that runs it so the sum can be checked on its own.
|
||||
*/
|
||||
public record DdsCommissionCalculation(BigDecimal collectionVolume, BigDecimal ratePercent, BigDecimal gross, BigDecimal tds,
|
||||
BigDecimal deductions, BigDecimal net) {
|
||||
|
||||
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
|
||||
private static final int MONEY_SCALE = 2;
|
||||
|
||||
/**
|
||||
* Works out a period's commission.
|
||||
*
|
||||
* @param collectionVolume
|
||||
* everything the agent collected in the period.
|
||||
* @param ratePercent
|
||||
* the matching slab's percentage.
|
||||
* @param tdsApplicable
|
||||
* whether tax is withheld from this agent at all.
|
||||
* @param tdsPercent
|
||||
* the rate withheld when it is.
|
||||
* @param deductions
|
||||
* the standing amounts the bank recovers - see {@code Agent.totalDeductions()}.
|
||||
*/
|
||||
public static DdsCommissionCalculation of(final BigDecimal collectionVolume, final BigDecimal ratePercent,
|
||||
final boolean tdsApplicable, final BigDecimal tdsPercent, final BigDecimal deductions) {
|
||||
|
||||
final BigDecimal volume = collectionVolume == null ? BigDecimal.ZERO : collectionVolume;
|
||||
final BigDecimal rate = ratePercent == null ? BigDecimal.ZERO : ratePercent;
|
||||
final BigDecimal recovered = deductions == null ? BigDecimal.ZERO : deductions;
|
||||
|
||||
final BigDecimal gross = volume.multiply(rate).divide(HUNDRED, MONEY_SCALE, RoundingMode.HALF_UP);
|
||||
final BigDecimal withheld = tdsApplicable && tdsPercent != null
|
||||
? gross.multiply(tdsPercent).divide(HUNDRED, MONEY_SCALE, RoundingMode.HALF_UP)
|
||||
: BigDecimal.ZERO;
|
||||
|
||||
return new DdsCommissionCalculation(volume, rate, gross, withheld, recovered, gross.subtract(withheld).subtract(recovered));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether there is anything to pay.
|
||||
*
|
||||
* The deductions can swallow a period's commission entirely. Nothing is owed then, and the agent
|
||||
* is not put into debt over it - the deductions are standing figures the bank adjusts, not a
|
||||
* balance a commission run is entitled to carry forward.
|
||||
*/
|
||||
public boolean isPayable() {
|
||||
return this.net.compareTo(BigDecimal.ZERO) > 0;
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.calculation;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* What a DDS account has earned over a period.
|
||||
*
|
||||
* Interest is on the <em>daily product</em>: the balance summed over the days it was actually held.
|
||||
* A customer paying in daily holds each instalment for a different length of time, so interest on
|
||||
* the closing balance for the whole period would pay them for money they had not yet deposited -
|
||||
* roughly double what they are owed, which is what this replaced.
|
||||
*
|
||||
* This is the same basis {@code Party.calculateMaturityAmount()} quotes at opening, so what the
|
||||
* interest runs add up to over a term matches the figure the customer was promised.
|
||||
*
|
||||
* <p>
|
||||
* Kept apart from the job that runs it so the sum can be checked on its own. It reads nothing and
|
||||
* writes nothing - given the same movements it always answers the same, which is what makes it
|
||||
* worth testing.
|
||||
*/
|
||||
public final class DdsInterestCalculation {
|
||||
|
||||
/** Wide enough that the intermediate division never loses precision before the final rounding. */
|
||||
private static final MathContext MATH_CONTEXT = new MathContext(19, RoundingMode.HALF_UP);
|
||||
|
||||
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
|
||||
private static final BigDecimal DAYS_IN_YEAR = BigDecimal.valueOf(365);
|
||||
private static final int MONEY_SCALE = 2;
|
||||
|
||||
private DdsInterestCalculation() {}
|
||||
|
||||
/**
|
||||
* One movement on the account: a collection is positive, a payout negative.
|
||||
*
|
||||
* A plain pair rather than the transaction itself, so this sum has no opinion about how
|
||||
* transactions are stored and can be checked with a handful of literals.
|
||||
*/
|
||||
public record Movement(LocalDate date, BigDecimal signedAmount) {}
|
||||
|
||||
/**
|
||||
* The balance summed over the days it was held, between {@code from} (exclusive) and {@code to}
|
||||
* (inclusive).
|
||||
*
|
||||
* @param openingBalance
|
||||
* what the account held before any of these movements.
|
||||
* @param movements
|
||||
* every movement up to the end of the period, oldest first. Anything dated on or
|
||||
* before {@code from} has already happened and makes up the balance this period opens
|
||||
* with rather than a movement within it.
|
||||
*/
|
||||
public static BigDecimal dailyProduct(final BigDecimal openingBalance, final List<Movement> movements, final LocalDate from,
|
||||
final LocalDate to) {
|
||||
|
||||
BigDecimal balance = openingBalance == null ? BigDecimal.ZERO : openingBalance;
|
||||
int index = 0;
|
||||
while (index < movements.size() && !movements.get(index).date().isAfter(from)) {
|
||||
balance = balance.add(movements.get(index).signedAmount());
|
||||
index++;
|
||||
}
|
||||
|
||||
BigDecimal product = BigDecimal.ZERO;
|
||||
LocalDate cursor = from;
|
||||
|
||||
while (index < movements.size()) {
|
||||
final LocalDate movementDate = movements.get(index).date();
|
||||
if (movementDate.isAfter(to)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// The untouched days between the last movement and this one, at the balance they stood at.
|
||||
final long daysAtOpeningBalance = ChronoUnit.DAYS.between(cursor, movementDate) - 1;
|
||||
if (daysAtOpeningBalance > 0) {
|
||||
product = product.add(balance.multiply(BigDecimal.valueOf(daysAtOpeningBalance)));
|
||||
}
|
||||
|
||||
// Apply every movement dated that day before counting the day itself, so a deposit earns
|
||||
// from the day it was made.
|
||||
while (index < movements.size() && movementDate.equals(movements.get(index).date())) {
|
||||
balance = balance.add(movements.get(index).signedAmount());
|
||||
index++;
|
||||
}
|
||||
product = product.add(balance);
|
||||
cursor = movementDate;
|
||||
}
|
||||
|
||||
final long remainingDays = ChronoUnit.DAYS.between(cursor, to);
|
||||
if (remainingDays > 0) {
|
||||
product = product.add(balance.multiply(BigDecimal.valueOf(remainingDays)));
|
||||
}
|
||||
|
||||
return product;
|
||||
}
|
||||
|
||||
/** Simple interest on a daily product at an annual rate, rounded to paise. */
|
||||
public static BigDecimal interestOn(final BigDecimal dailyProduct, final BigDecimal ratePercent) {
|
||||
if (dailyProduct == null || ratePercent == null || dailyProduct.compareTo(BigDecimal.ZERO) <= 0
|
||||
|| ratePercent.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return BigDecimal.ZERO.setScale(MONEY_SCALE);
|
||||
}
|
||||
return dailyProduct.multiply(ratePercent).divide(HUNDRED.multiply(DAYS_IN_YEAR), MATH_CONTEXT).setScale(MONEY_SCALE,
|
||||
RoundingMode.HALF_UP);
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.calculation;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
|
||||
/**
|
||||
* Where a daily deposit account stands against the instalments it owes.
|
||||
*
|
||||
* The bank's rule has two sides and they are not symmetrical. A customer may run ahead, but only a few
|
||||
* days - the scheme is a habit of saving daily, not a deposit paid in one go. A customer may fall
|
||||
* behind and catch up, but only within a short window; instalments older than that are skipped, and
|
||||
* skipped means gone - the agent does not go back for them, and the account matures on what was
|
||||
* actually paid.
|
||||
*
|
||||
* So a shortfall is split rather than shown as one figure. What can still be collected is worth a
|
||||
* clerk chasing today; what has passed the window is not, and showing them together would have the
|
||||
* clerk asking for money the bank has already written off.
|
||||
*
|
||||
* Kept here, outside either service, because the screen that shows these figures and the check that
|
||||
* refuses a collection exceeding them have to agree. Two copies would drift, and the day they did the
|
||||
* screen would quote a customer an amount the server then refused.
|
||||
*
|
||||
* @param expected what should have been collected by the day this was worked out
|
||||
* @param advance signed: positive is paid ahead, negative is behind
|
||||
* @param advanceDays whole instalments covered ahead - a part-paid day does not count
|
||||
* @param arrearsDays whole or part instalments behind
|
||||
* @param recoverable the part of a shortfall still inside the catch-up window
|
||||
* @param skippedDays instalments past that window
|
||||
* @param skipped what those come to
|
||||
*/
|
||||
public record DdsSchedule(BigDecimal expected, BigDecimal advance, int advanceDays, int arrearsDays, BigDecimal recoverable,
|
||||
int skippedDays, BigDecimal skipped) {
|
||||
|
||||
/**
|
||||
* How far behind an account may fall and still be caught up. Past this the instalments are
|
||||
* skipped - the agent does not go back for them and the customer matures on less.
|
||||
*/
|
||||
public static final int CATCH_UP_DAYS = 3;
|
||||
|
||||
/**
|
||||
* The most a customer may pay ahead, so a scheme is not simply prepaid into a lump deposit. A few
|
||||
* days is the bank's allowance for someone going away or paid weekly.
|
||||
*/
|
||||
public static final int MAX_ADVANCE_DAYS = 5;
|
||||
|
||||
private static final DdsSchedule NOTHING_DUE = new DdsSchedule(BigDecimal.ZERO, BigDecimal.ZERO, 0, 0, BigDecimal.ZERO, 0,
|
||||
BigDecimal.ZERO);
|
||||
|
||||
/**
|
||||
* How many instalments should have been paid by the given day.
|
||||
*
|
||||
* The first falls on the opening day itself - the same day the maturity figure starts counting
|
||||
* from, so the two agree - and the count stops at the term's own length, so a matured account does
|
||||
* not keep falling further behind.
|
||||
*/
|
||||
public static int instalmentsDue(final LocalDate openingDate, final Integer termDays, final LocalDate on) {
|
||||
if (openingDate == null || termDays == null || termDays <= 0 || on == null) {
|
||||
return 0;
|
||||
}
|
||||
final long elapsed = ChronoUnit.DAYS.between(openingDate, on) + 1;
|
||||
return (int) Math.max(0, Math.min(termDays, elapsed));
|
||||
}
|
||||
|
||||
public static DdsSchedule of(final BigDecimal daily, final int instalmentsDue, final BigDecimal collected) {
|
||||
if (daily == null || daily.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
// Nothing is due daily, so there is no schedule to be ahead or behind of.
|
||||
return NOTHING_DUE;
|
||||
}
|
||||
|
||||
final BigDecimal paid = collected == null ? BigDecimal.ZERO : collected;
|
||||
final BigDecimal expected = daily.multiply(BigDecimal.valueOf(instalmentsDue));
|
||||
final BigDecimal advance = paid.subtract(expected);
|
||||
|
||||
if (advance.compareTo(BigDecimal.ZERO) >= 0) {
|
||||
// Ahead. Only whole instalments count as days covered - a customer eighty rupees into a
|
||||
// hundred-rupee day still owes today, and the agent should still call.
|
||||
final int daysCovered = advance.divideToIntegralValue(daily).intValue();
|
||||
return new DdsSchedule(expected, advance, daysCovered, 0, BigDecimal.ZERO, 0, BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
// Behind. A part-paid day counts as a day behind for the same reason.
|
||||
final BigDecimal shortfall = advance.negate();
|
||||
final int daysBehind = shortfall.divide(daily, 0, RoundingMode.CEILING).intValue();
|
||||
final BigDecimal window = daily.multiply(BigDecimal.valueOf(CATCH_UP_DAYS));
|
||||
final BigDecimal recoverable = shortfall.min(window);
|
||||
return new DdsSchedule(expected, advance, 0, daysBehind, recoverable, Math.max(0, daysBehind - CATCH_UP_DAYS),
|
||||
shortfall.subtract(recoverable));
|
||||
}
|
||||
|
||||
/** Whole instalments a customer may still pay off, which is what the arrears option may offer. */
|
||||
public int recoverableDays(final BigDecimal daily) {
|
||||
if (daily == null || daily.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return this.recoverable.divideToIntegralValue(daily).intValue();
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* An agent's day, as the clerk sees it when the agent walks up to the counter.
|
||||
*
|
||||
* The figure that matters is {@code expectedAmount}: what the agent's own entries add up to, and so
|
||||
* what should be in their bag. It is given as a total beside the lines it came from, because a clerk
|
||||
* who is short counts the lines to find out where.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class AgentCollectionSummaryData {
|
||||
|
||||
private final Long agentId;
|
||||
private final String agentCode;
|
||||
private final String agentName;
|
||||
private final LocalDate entryDate;
|
||||
|
||||
private final int pendingCount;
|
||||
|
||||
/** What the agent's outstanding entries add up to - the cash the branch should be handed. */
|
||||
private final BigDecimal expectedAmount;
|
||||
|
||||
/** What has already been counted and posted for this agent on this date. */
|
||||
private final int settledCount;
|
||||
private final BigDecimal settledAmount;
|
||||
|
||||
private final List<CollectionEntryData> entries;
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* One written-down collection, as the agent's screen and the clerk's settlement list show it.
|
||||
*
|
||||
* Carries the party's code, name and balance beside the amount, because both readers are checking
|
||||
* the line against a person rather than an id - the agent against the customer they just left, the
|
||||
* clerk against the passbook in front of them.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class CollectionEntryData {
|
||||
|
||||
private final Long id;
|
||||
|
||||
private final Long agentId;
|
||||
private final String agentCode;
|
||||
private final String agentName;
|
||||
|
||||
private final Long partyId;
|
||||
private final String partyCode;
|
||||
private final String partyName;
|
||||
|
||||
/** The account's balance as it stands now - before this entry, which has not been posted. */
|
||||
private final BigDecimal partyBalance;
|
||||
|
||||
/** What this scheme takes each day, so an amount that is not a day's instalment stands out. */
|
||||
private final BigDecimal dailyDepositAmount;
|
||||
|
||||
private final LocalDate entryDate;
|
||||
private final BigDecimal amount;
|
||||
private final String transactionMode;
|
||||
private final String chequeNo;
|
||||
private final String tokenNo;
|
||||
private final String description;
|
||||
|
||||
private final String status;
|
||||
private final LocalDate settledOn;
|
||||
private final Long partyTransactionId;
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||
|
||||
/**
|
||||
* One line an agent wrote down on their round: this customer, this much, in this form.
|
||||
*
|
||||
* It is not a transaction and deliberately reads nothing like one. No balance is carried on it, no
|
||||
* ledger entry stands behind it, and no report of the bank's position counts it. It says only what
|
||||
* the agent says they collected - the bank's own record of it begins when a clerk has counted the
|
||||
* cash and confirmed the day's entries, and what that writes is an ordinary
|
||||
* {@code m_dds_party_transaction} row.
|
||||
*
|
||||
* The distinction is the whole point of the table. Credited on the spot, the books showed customers
|
||||
* paid in notes the branch had never held, and there was nothing to count the agent's bag against
|
||||
* when they arrived. Held here, the agent's word and the bank's books stay separate until somebody
|
||||
* puts them side by side.
|
||||
*/
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "m_dds_collection_entry")
|
||||
public class CollectionEntry extends AbstractAuditableCustom {
|
||||
|
||||
/** Waiting for a clerk to count the cash behind it. */
|
||||
public static final String PENDING = "PENDING";
|
||||
|
||||
/** Counted, posted, and now standing behind a real transaction. */
|
||||
public static final String SETTLED = "SETTLED";
|
||||
|
||||
/** Withdrawn before it was ever posted - a wrong line the agent took back. */
|
||||
public static final String CANCELLED = "CANCELLED";
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "agent_id", nullable = false)
|
||||
private Agent agent;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "party_id", nullable = false)
|
||||
private Party party;
|
||||
|
||||
/**
|
||||
* The day the money changed hands, which is the agent's day and not the branch's. A round
|
||||
* collected on Friday and brought in on Monday is still Friday's collection, and the customer's
|
||||
* statement has to say so.
|
||||
*/
|
||||
@Column(name = "entry_date", nullable = false)
|
||||
private LocalDate entryDate;
|
||||
|
||||
@Column(name = "amount", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal amount;
|
||||
|
||||
@Column(name = "transaction_mode", length = 20, nullable = false)
|
||||
private String transactionMode;
|
||||
|
||||
@Column(name = "cheque_no", length = 50)
|
||||
private String chequeNo;
|
||||
|
||||
@Column(name = "token_no", length = 50)
|
||||
private String tokenNo;
|
||||
|
||||
@Column(name = "description", length = 200)
|
||||
private String description;
|
||||
|
||||
@Column(name = "status", length = 20, nullable = false)
|
||||
private String status;
|
||||
|
||||
@Column(name = "settled_on")
|
||||
private LocalDate settledOn;
|
||||
|
||||
/**
|
||||
* The transaction this entry became, once it was posted.
|
||||
*
|
||||
* Held as a plain id rather than a mapped association: this table is the paper the bank checked
|
||||
* against, not part of the ledger, and a link that pulled a transaction in whenever an entry was
|
||||
* read would make the two look like halves of one thing. Anyone who needs the transaction can
|
||||
* fetch it; most callers only need to know there is one.
|
||||
*/
|
||||
@Column(name = "party_transaction_id")
|
||||
private Long partyTransactionId;
|
||||
|
||||
protected CollectionEntry() {
|
||||
//
|
||||
}
|
||||
|
||||
public CollectionEntry(final Agent agent, final Party party, final LocalDate entryDate, final BigDecimal amount,
|
||||
final String transactionMode, final String chequeNo, final String tokenNo, final String description) {
|
||||
this.agent = agent;
|
||||
this.party = party;
|
||||
this.entryDate = entryDate;
|
||||
this.amount = amount;
|
||||
this.transactionMode = transactionMode;
|
||||
this.chequeNo = chequeNo;
|
||||
this.tokenNo = tokenNo;
|
||||
this.description = description;
|
||||
this.status = PENDING;
|
||||
}
|
||||
|
||||
public boolean isPending() {
|
||||
return PENDING.equals(this.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that this entry has been counted and posted, and what it became.
|
||||
*
|
||||
* The transaction's id is kept so the two can always be put back together - a clerk asked six
|
||||
* months later which entry a credit came from has an answer, rather than a matching exercise on
|
||||
* date and amount.
|
||||
*/
|
||||
public void markSettled(final LocalDate settledOn, final Long partyTransactionId) {
|
||||
this.status = SETTLED;
|
||||
this.settledOn = settledOn;
|
||||
this.partyTransactionId = partyTransactionId;
|
||||
}
|
||||
|
||||
/** Withdraws a line that was never posted. Kept, not deleted - the agent wrote it, so it stands. */
|
||||
public void markCancelled() {
|
||||
this.status = CANCELLED;
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||
|
||||
public class CollectionEntryNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||
|
||||
public CollectionEntryNotFoundException(final Long entryId) {
|
||||
super("error.msg.dds.collection.entry.not.found", "Collection entry " + entryId + " does not exist", entryId);
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a written-down collection is taken back after it has already been settled.
|
||||
*
|
||||
* By then it is not a note any more - a customer has been credited and an agent's cash recorded
|
||||
* against it. Withdrawing it here would leave the note cancelled and the credit standing, which is
|
||||
* the one state nobody could reconcile. A posted collection is undone the way any posting is: with
|
||||
* an entry the other way, on the ledger, where it can be seen.
|
||||
*/
|
||||
public class CollectionEntryNotPendingException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public CollectionEntryNotPendingException(final Long entryId, final String status) {
|
||||
super("error.msg.dds.collection.entry.not.pending",
|
||||
"Collection entry " + entryId + " is " + status.toLowerCase() + " and can no longer be cancelled", entryId, status);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a settlement is asked for and the agent has nothing outstanding on that date.
|
||||
*
|
||||
* Said plainly rather than passed off as a settlement of nothing, because the clerk is standing at
|
||||
* the counter with an agent in front of them: either they have the wrong date, or the round has been
|
||||
* settled already, and both are worth knowing before the cash is taken.
|
||||
*/
|
||||
public class NothingToSettleException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public NothingToSettleException(final String agentCode, final String entryDate) {
|
||||
super("error.msg.dds.collection.nothing.to.settle",
|
||||
"Agent " + agentCode + " has no collection entries outstanding for " + entryDate, agentCode, entryDate);
|
||||
}
|
||||
}
|
||||
+73
@@ -44,8 +44,37 @@ public final class PartyData implements Serializable {
|
||||
private final Long agentId;
|
||||
private final String agentCode;
|
||||
private final String agentName;
|
||||
/** The branch this account belongs to - its own, not the collecting agent's. */
|
||||
private final Long officeId;
|
||||
private final String officeName;
|
||||
/**
|
||||
* The customer's real deposit account, opened when the party was created.
|
||||
*
|
||||
* The id alone was carried here before, which is a number only the database means anything by -
|
||||
* a screen showing "36" tells an officer nothing. The account's own number, product, status and
|
||||
* balance are read from the account itself, so what a party shows is what the bank's books say
|
||||
* rather than a second copy that can drift. All four are null for a party opened before DDS
|
||||
* began opening real accounts, and that is worth showing plainly rather than hiding.
|
||||
*/
|
||||
private final Long savingsAccountId;
|
||||
private final String savingsAccountNo;
|
||||
private final String savingsProductName;
|
||||
private final String savingsAccountStatus;
|
||||
private final BigDecimal savingsAccountBalance;
|
||||
/**
|
||||
* What on this account is pledged against a loan and cannot leave.
|
||||
*
|
||||
* Shown beside the balance on the closure list because a deposit standing as security is owed but
|
||||
* not payable, and a clerk who reads only the balance would try to pay it and be refused.
|
||||
*/
|
||||
private final BigDecimal amountHeldUnderLien;
|
||||
private final String subType;
|
||||
/**
|
||||
* The account type's description, resolved from the DDS Account Type master that {@code subType}
|
||||
* holds the code of - so callers can show the scheme by the name officers know it by without
|
||||
* fetching the master separately.
|
||||
*/
|
||||
private final String subTypeDescription;
|
||||
private final String partyCode;
|
||||
private final String lastAccount;
|
||||
private final Boolean nominal;
|
||||
@@ -68,6 +97,21 @@ public final class PartyData implements Serializable {
|
||||
private final String ckycId;
|
||||
private final String adultMinor;
|
||||
private final LocalDate minorDate;
|
||||
|
||||
/**
|
||||
* The day a minor account holder comes of age, and whether it has passed.
|
||||
*
|
||||
* Worked out from the birth date rather than taken from {@code minorDate}, which is typed in by
|
||||
* hand and has never been filled on any record. Null on an adult's account.
|
||||
*
|
||||
* The guardian's authority to operate the account ends on that day, so the branch has to take
|
||||
* fresh KYC, a signature and a new mandate from the customer themselves. Nothing acts on this
|
||||
* yet - whether the account should also be restricted until that is done is the bank's rule, and
|
||||
* blocking one wrongly would strand a customer's money. It is carried here so the day stops
|
||||
* passing unnoticed, which is what happened before.
|
||||
*/
|
||||
private final LocalDate majorOn;
|
||||
private final boolean minorTurnedMajor;
|
||||
private final Long guardianClientId;
|
||||
private final String minAccountOpt;
|
||||
private final String relation;
|
||||
@@ -85,14 +129,43 @@ public final class PartyData implements Serializable {
|
||||
private final Boolean od1Flag;
|
||||
private final Boolean od2Flag;
|
||||
private final Boolean lien;
|
||||
/** Why the money is held - null when it is not. */
|
||||
private final String lienReason;
|
||||
private final LocalDate payDate;
|
||||
private final BigDecimal paymentAmount;
|
||||
private final BigDecimal currentBalance;
|
||||
private final BigDecimal openingBalance;
|
||||
|
||||
/**
|
||||
* Where this account stands against the instalments it should have paid by today.
|
||||
*
|
||||
* A daily scheme is a promise to pay a fixed sum every day, and the only figure the screen used to
|
||||
* show was the running balance - which says how much has been paid but not whether it was paid on
|
||||
* time. A customer eleven instalments behind and one who is four ahead can hold the same balance.
|
||||
*
|
||||
* {@code advanceAmount} is signed: positive is paid ahead, negative is behind. The rest of these
|
||||
* read it in the bank's own terms - how many days that comes to, how much of a shortfall can still
|
||||
* be recovered within the catch-up window, and how much has fallen outside it and is gone.
|
||||
*/
|
||||
private final Integer instalmentsDue;
|
||||
private final BigDecimal expectedToDate;
|
||||
private final BigDecimal collectedToDate;
|
||||
private final BigDecimal advanceAmount;
|
||||
private final Integer advanceDays;
|
||||
private final Integer arrearsDays;
|
||||
private final BigDecimal recoverableAmount;
|
||||
/** Instalments now past the catch-up window. These are not collected again - they are lost. */
|
||||
private final Integer skippedDays;
|
||||
private final BigDecimal skippedAmount;
|
||||
private final Boolean isSmsActive;
|
||||
private final Boolean isEmailActive;
|
||||
/** Interest worked out but not yet credited, and how far it is calculated through. */
|
||||
private final BigDecimal interestCalc;
|
||||
private final LocalDate interestDate;
|
||||
|
||||
/** The same pair for the OD interest stream, which runs and posts independently. */
|
||||
private final BigDecimal odInterestCalc;
|
||||
private final LocalDate odInterestDate;
|
||||
private final String spDescription;
|
||||
private final String freezeCode;
|
||||
private final String advCode;
|
||||
|
||||
+13
-3
@@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.dds.party.data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Getter;
|
||||
|
||||
@@ -38,14 +39,21 @@ public final class PartyNomineeData implements Serializable {
|
||||
private final String relation;
|
||||
private final String gender;
|
||||
private final String guardianName;
|
||||
/** Null under a Sequence split, where the order the nominees are paid in is the entitlement. */
|
||||
private final BigDecimal sharePercent;
|
||||
/** Null under a Percentage split, where everyone is paid at once and there is no first. */
|
||||
private final Integer sequenceNo;
|
||||
|
||||
public static PartyNomineeData instance(final Long id, final LocalDate nominationDate, final String name, final String mobileNo,
|
||||
final LocalDate birthDate, final Boolean minor, final String relation, final String gender, final String guardianName) {
|
||||
return new PartyNomineeData(id, nominationDate, name, mobileNo, birthDate, minor, relation, gender, guardianName);
|
||||
final LocalDate birthDate, final Boolean minor, final String relation, final String gender, final String guardianName,
|
||||
final BigDecimal sharePercent, final Integer sequenceNo) {
|
||||
return new PartyNomineeData(id, nominationDate, name, mobileNo, birthDate, minor, relation, gender, guardianName, sharePercent,
|
||||
sequenceNo);
|
||||
}
|
||||
|
||||
private PartyNomineeData(final Long id, final LocalDate nominationDate, final String name, final String mobileNo,
|
||||
final LocalDate birthDate, final Boolean minor, final String relation, final String gender, final String guardianName) {
|
||||
final LocalDate birthDate, final Boolean minor, final String relation, final String gender, final String guardianName,
|
||||
final BigDecimal sharePercent, final Integer sequenceNo) {
|
||||
this.id = id;
|
||||
this.nominationDate = nominationDate;
|
||||
this.name = name;
|
||||
@@ -55,5 +63,7 @@ public final class PartyNomineeData implements Serializable {
|
||||
this.relation = relation;
|
||||
this.gender = gender;
|
||||
this.guardianName = guardianName;
|
||||
this.sharePercent = sharePercent;
|
||||
this.sequenceNo = sequenceNo;
|
||||
}
|
||||
}
|
||||
|
||||
+219
-15
@@ -29,7 +29,10 @@ import jakarta.persistence.OrderBy;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -38,8 +41,9 @@ import java.util.function.Consumer;
|
||||
import lombok.Getter;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
||||
import org.apache.fineract.organisation.office.domain.Office;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
|
||||
/**
|
||||
@@ -54,11 +58,16 @@ import org.apache.fineract.portfolio.client.domain.Client;
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "m_dds_party", uniqueConstraints = { @UniqueConstraint(columnNames = { "party_code" }, name = "party_code_UNIQUE") })
|
||||
public class Party extends AbstractPersistableCustom<Long> {
|
||||
public class Party extends AbstractAuditableCustom {
|
||||
|
||||
/** 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");
|
||||
|
||||
/** Interest is quoted per annum, so the daily instalments are pro-rated over a year. */
|
||||
private static final int DAYS_IN_YEAR = 365;
|
||||
|
||||
private static final MathContext MATH_CONTEXT = new MathContext(19, RoundingMode.HALF_UP);
|
||||
|
||||
public static final String STATUS_ACTIVE = "ACTIVE";
|
||||
public static final String STATUS_CLOSED = "CLOSED";
|
||||
|
||||
@@ -70,6 +79,18 @@ public class Party extends AbstractPersistableCustom<Long> {
|
||||
@JoinColumn(name = "agent_id", nullable = false)
|
||||
private Agent agent;
|
||||
|
||||
/**
|
||||
* The branch this account belongs to.
|
||||
*
|
||||
* Its own, not the collecting agent's. An agent working out of one branch can hold accounts for
|
||||
* customers of another, and reassigning an account to a different agent should not move the
|
||||
* customer's branch with it. Defaults to the agent's branch when the caller does not name one,
|
||||
* which is what the account was read as before it had a branch of its own.
|
||||
*/
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "office_id")
|
||||
private Office office;
|
||||
|
||||
@Column(name = "savings_account_id")
|
||||
private Long savingsAccountId;
|
||||
|
||||
@@ -190,9 +211,29 @@ public class Party extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "od2_flag", nullable = false)
|
||||
private boolean od2Flag;
|
||||
|
||||
/**
|
||||
* Whether this account's money is being held, and so cannot leave.
|
||||
*
|
||||
* Deposits are unaffected - the agent collects as before and interest posts as before. What is
|
||||
* refused is every way out: the maturity payout, an early closure, and a transfer to another
|
||||
* party. A lien that stopped the collections would defeat the scheme it is securing.
|
||||
*/
|
||||
@Column(name = "lien", nullable = false)
|
||||
private boolean lien;
|
||||
|
||||
/**
|
||||
* Why the money is held.
|
||||
*
|
||||
* The flag alone says an account is frozen and never says what for, which is how a held deposit
|
||||
* outlives the loan it secured: months later nobody can tell whether it has been repaid, so
|
||||
* nobody will release it and nobody can justify keeping it.
|
||||
*
|
||||
* It falls to a line of text because DDS and the loan module are not connected - nothing else in
|
||||
* the system links a held deposit to the loan behind it.
|
||||
*/
|
||||
@Column(name = "lien_reason", length = 200)
|
||||
private String lienReason;
|
||||
|
||||
@Column(name = "pay_date")
|
||||
private LocalDate payDate;
|
||||
|
||||
@@ -211,12 +252,24 @@ public class Party extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "is_email_active", nullable = false)
|
||||
private boolean isEmailActive;
|
||||
|
||||
/** Interest worked out but not yet credited - the legacy screen's "Int.Calc". */
|
||||
@Column(name = "interest_calc", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal interestCalc;
|
||||
|
||||
/** How far regular interest has been calculated through. */
|
||||
@Column(name = "interest_date")
|
||||
private LocalDate interestDate;
|
||||
|
||||
/**
|
||||
* OD interest is a second stream over the same account, run and posted independently of regular
|
||||
* interest, so it keeps its own accrued figure and through-date rather than sharing them.
|
||||
*/
|
||||
@Column(name = "od_interest_calc", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal odInterestCalc;
|
||||
|
||||
@Column(name = "od_interest_date")
|
||||
private LocalDate odInterestDate;
|
||||
|
||||
@Column(name = "sp_description", length = 500)
|
||||
private String spDescription;
|
||||
|
||||
@@ -255,9 +308,10 @@ public class Party extends AbstractPersistableCustom<Long> {
|
||||
//
|
||||
}
|
||||
|
||||
private Party(final Client client, final Agent agent, final String partyCode, final JsonCommand command) {
|
||||
private Party(final Client client, final Agent agent, final Office office, final String partyCode, final JsonCommand command) {
|
||||
this.client = client;
|
||||
this.agent = agent;
|
||||
this.office = office;
|
||||
this.partyCode = partyCode;
|
||||
this.subType = command.stringValueOfParameterNamed("subType");
|
||||
this.lastAccount = command.stringValueOfParameterNamedAllowingNull("lastAccount");
|
||||
@@ -286,7 +340,8 @@ public class Party extends AbstractPersistableCustom<Long> {
|
||||
this.relation = command.stringValueOfParameterNamedAllowingNull("relation");
|
||||
this.openingDate = command.localDateValueOfParameterNamed("openingDate");
|
||||
this.month = command.integerValueOfParameterNamedDefaultToNullIfZero("month");
|
||||
this.maturityDate = command.localDateValueOfParameterNamed("maturityDate");
|
||||
// maturityDate and maturityAmount are not read from the request - recalculateTerm() below
|
||||
// works both out from the opening date and the terms.
|
||||
this.reopenDate = command.localDateValueOfParameterNamed("reopenDate");
|
||||
this.depositAmount = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("depositAmount"));
|
||||
this.dailyDepositAmount = command.bigDecimalValueOfParameterNamed("dailyDepositAmount");
|
||||
@@ -300,6 +355,7 @@ public class Party extends AbstractPersistableCustom<Long> {
|
||||
this.od1Flag = false;
|
||||
this.od2Flag = false;
|
||||
this.lien = command.booleanPrimitiveValueOfParameterNamed("lien");
|
||||
this.lienReason = StringUtils.trimToNull(command.stringValueOfParameterNamedAllowingNull("lienReason"));
|
||||
this.payDate = command.localDateValueOfParameterNamed("payDate");
|
||||
this.paymentAmount = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("paymentAmount"));
|
||||
this.openingBalance = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("openingBalance"));
|
||||
@@ -308,6 +364,8 @@ public class Party extends AbstractPersistableCustom<Long> {
|
||||
this.isEmailActive = command.booleanPrimitiveValueOfParameterNamed("isEmailActive");
|
||||
this.interestCalc = BigDecimal.ZERO;
|
||||
this.interestDate = command.localDateValueOfParameterNamed("interestDate");
|
||||
this.odInterestCalc = BigDecimal.ZERO;
|
||||
this.odInterestDate = null;
|
||||
this.spDescription = command.stringValueOfParameterNamedAllowingNull("spDescription");
|
||||
this.freezeCode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("freezeCode"), "Normal");
|
||||
this.advCode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("advCode"), "None");
|
||||
@@ -318,10 +376,76 @@ public class Party extends AbstractPersistableCustom<Long> {
|
||||
this.nomineeSplitMode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("nomineeSplitMode"),
|
||||
"Percentage");
|
||||
this.status = STATUS_ACTIVE;
|
||||
|
||||
recalculateTerm();
|
||||
}
|
||||
|
||||
public static Party fromJson(final Client client, final Agent agent, final String partyCode, final JsonCommand command) {
|
||||
return new Party(client, agent, partyCode, command);
|
||||
/**
|
||||
* Works out the maturity date and amount from the terms just set.
|
||||
*
|
||||
* Both are quoted to the customer when the account is opened, so neither is taken from the
|
||||
* request: a figure the caller supplies is a figure the caller can get wrong or tamper with.
|
||||
* The screen shows the same numbers as a preview, worked out the same way.
|
||||
*/
|
||||
private void recalculateTerm() {
|
||||
if (this.openingDate != null) {
|
||||
if (this.month != null && this.month > 0) {
|
||||
this.maturityDate = this.openingDate.plusMonths(this.month);
|
||||
// Collection runs every day of the term without a break, so the number of instalments
|
||||
// is the number of days - it is not a separate thing for anyone to type.
|
||||
//
|
||||
// It was asked for, and a figure below the term's own length quietly broke the
|
||||
// maturity quote: an account collecting a hundred rupees a day for thirty days with
|
||||
// ten instalments keyed against it was quoted a thousand rupees and would collect
|
||||
// three. Working it out here means the two can no longer disagree.
|
||||
this.days = (int) ChronoUnit.DAYS.between(this.openingDate, this.maturityDate);
|
||||
} else if (this.days != null && this.days > 0) {
|
||||
// No month given, so the term is the run of days itself and days stands as entered.
|
||||
this.maturityDate = this.openingDate.plusDays(this.days);
|
||||
}
|
||||
}
|
||||
this.maturityAmount = calculateMaturityAmount();
|
||||
}
|
||||
|
||||
/**
|
||||
* What the account matures to: every instalment paid in, plus simple interest on each for the
|
||||
* time it sits until maturity.
|
||||
*
|
||||
* The first instalment earns over the whole term and the last over none, which comes to
|
||||
* n(n+1)/2 instalment-days of interest across n instalments. At a zero rate this is just the
|
||||
* instalments themselves.
|
||||
*/
|
||||
private BigDecimal calculateMaturityAmount() {
|
||||
if (this.dailyDepositAmount == null || this.days == null || this.days <= 0
|
||||
|| this.dailyDepositAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
final BigDecimal instalments = BigDecimal.valueOf(this.days);
|
||||
final BigDecimal principal = this.dailyDepositAmount.multiply(instalments);
|
||||
|
||||
final BigDecimal rate = this.interestRate == null ? BigDecimal.ZERO : this.interestRate;
|
||||
if (rate.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return principal.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
final BigDecimal instalmentDays = instalments.multiply(instalments.add(BigDecimal.ONE))
|
||||
.divide(BigDecimal.valueOf(2), MATH_CONTEXT);
|
||||
final BigDecimal interest = this.dailyDepositAmount.multiply(instalmentDays).multiply(rate)
|
||||
.divide(BigDecimal.valueOf(100), MATH_CONTEXT)
|
||||
.divide(BigDecimal.valueOf(DAYS_IN_YEAR), MATH_CONTEXT);
|
||||
|
||||
return principal.add(interest).setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
public static Party fromJson(final Client client, final Agent agent, final Office office, final String partyCode,
|
||||
final JsonCommand command) {
|
||||
return new Party(client, agent, office, partyCode, command);
|
||||
}
|
||||
|
||||
/** Moves this account to another branch, which a party update may do on its own. */
|
||||
public void changeOffice(final Office newOffice) {
|
||||
this.office = newOffice;
|
||||
}
|
||||
|
||||
private static BigDecimal orZero(final BigDecimal value) {
|
||||
@@ -364,6 +488,27 @@ public class Party extends AbstractPersistableCustom<Long> {
|
||||
return this.currentBalance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a minor's account over to its holder, now that they have come of age and been identified.
|
||||
*
|
||||
* Until this the guardian operates the account and the bank deals with them. After it the customer
|
||||
* holds it in their own right, so the guardian is kept only as a record of who stood for them
|
||||
* before - the account is no longer theirs to operate.
|
||||
*
|
||||
* Not done on the birthday alone. An eighteen-year-old the bank has not yet identified is somebody
|
||||
* it cannot pay, so the turnover waits on their own KYC being verified.
|
||||
*/
|
||||
public void becomeAdult() {
|
||||
this.adultMinor = ADULT;
|
||||
}
|
||||
|
||||
public boolean isMinor() {
|
||||
return MINOR.equalsIgnoreCase(this.adultMinor);
|
||||
}
|
||||
|
||||
private static final String MINOR = "Minor";
|
||||
private static final String ADULT = "Adult";
|
||||
|
||||
public boolean isClosed() {
|
||||
return STATUS_CLOSED.equals(this.status);
|
||||
}
|
||||
@@ -379,11 +524,50 @@ public class Party extends AbstractPersistableCustom<Long> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamps the date interest (regular or OD) was last calculated up to, so the next batch run
|
||||
* accrues only the days since. Set on POST, not on calculate/preview.
|
||||
* Interest Calculation: parks the interest earned since {@code interestDate} in the account's
|
||||
* accrued-interest figure - the legacy screen's "Int.Calc" - and stamps the date interest is now
|
||||
* calculated up to. The balance is untouched, because interest is not the customer's to draw on
|
||||
* until it is posted.
|
||||
*
|
||||
* The stamp is what keeps this safe to re-run: a second calculation for the same date finds no
|
||||
* days left to accrue, so the same interest cannot be earned twice.
|
||||
*/
|
||||
public void markInterestCalculatedThrough(final LocalDate date) {
|
||||
this.interestDate = date;
|
||||
public void accrueInterest(final BigDecimal amount, final LocalDate throughDate, final boolean odInterest) {
|
||||
if (odInterest) {
|
||||
this.odInterestCalc = this.odInterestCalc.add(amount);
|
||||
this.odInterestDate = throughDate;
|
||||
} else {
|
||||
this.interestCalc = this.interestCalc.add(amount);
|
||||
this.interestDate = throughDate;
|
||||
}
|
||||
}
|
||||
|
||||
/** How far the given interest stream has been calculated through - where the next run starts from. */
|
||||
public LocalDate interestCalculatedThrough(final boolean odInterest) {
|
||||
return odInterest ? this.odInterestDate : this.interestDate;
|
||||
}
|
||||
|
||||
/** What the given interest stream is holding, waiting to be posted. */
|
||||
public BigDecimal accruedInterest(final boolean odInterest) {
|
||||
return odInterest ? this.odInterestCalc : this.interestCalc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interest Posting: moves the given amount out of accrued interest and into the balance, and
|
||||
* returns the new balance.
|
||||
*
|
||||
* The amount is the one the run previewed rather than whatever is accrued at this moment, so what
|
||||
* gets credited is always what was seen and approved. Since it comes off the accrued figure, a
|
||||
* second posting of the same interest finds nothing left to move.
|
||||
*/
|
||||
public BigDecimal postInterest(final BigDecimal amount, final boolean odInterest) {
|
||||
if (odInterest) {
|
||||
this.odInterestCalc = this.odInterestCalc.subtract(amount);
|
||||
} else {
|
||||
this.interestCalc = this.interestCalc.subtract(amount);
|
||||
}
|
||||
this.currentBalance = this.currentBalance.add(amount);
|
||||
return this.currentBalance;
|
||||
}
|
||||
|
||||
public Long clientId() {
|
||||
@@ -463,11 +647,6 @@ public class Party extends AbstractPersistableCustom<Long> {
|
||||
this.month = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInDateParameterNamed("maturityDate", this.maturityDate)) {
|
||||
actualChanges.put("maturityDate", command.stringValueOfParameterNamed("maturityDate"));
|
||||
this.maturityDate = command.localDateValueOfParameterNamed("maturityDate");
|
||||
}
|
||||
|
||||
if (command.isChangeInDateParameterNamed("reopenDate", this.reopenDate)) {
|
||||
actualChanges.put("reopenDate", command.stringValueOfParameterNamed("reopenDate"));
|
||||
this.reopenDate = command.localDateValueOfParameterNamed("reopenDate");
|
||||
@@ -506,6 +685,19 @@ public class Party extends AbstractPersistableCustom<Long> {
|
||||
this.lien = newValue;
|
||||
}
|
||||
|
||||
stringChange(command, actualChanges, "lienReason", this.lienReason, v -> this.lienReason = v);
|
||||
|
||||
// A released account explains nothing, so the reason goes with the hold. Leaving it behind
|
||||
// would have the screen read "Lien: No, held for Loan #1234" - a sentence that contradicts
|
||||
// itself, and which somebody will one day read as the account still being held.
|
||||
//
|
||||
// Nothing is lost: what it was held for, who released it and when are all on the command the
|
||||
// release rode in on, which maker-checker keeps.
|
||||
if (!this.lien && this.lienReason != null) {
|
||||
actualChanges.put("lienReason", null);
|
||||
this.lienReason = null;
|
||||
}
|
||||
|
||||
if (command.isChangeInDateParameterNamed("payDate", this.payDate)) {
|
||||
actualChanges.put("payDate", command.stringValueOfParameterNamed("payDate"));
|
||||
this.payDate = command.localDateValueOfParameterNamed("payDate");
|
||||
@@ -555,6 +747,18 @@ public class Party extends AbstractPersistableCustom<Long> {
|
||||
|
||||
stringChange(command, actualChanges, "nomineeSplitMode", this.nomineeSplitMode, v -> this.nomineeSplitMode = v);
|
||||
|
||||
// Correcting a term, an instalment or the rate has to move the maturity figures with it -
|
||||
// otherwise the account keeps quoting what it was worth under the old terms.
|
||||
final LocalDate maturityDateBefore = this.maturityDate;
|
||||
final BigDecimal maturityAmountBefore = this.maturityAmount;
|
||||
recalculateTerm();
|
||||
if (!java.util.Objects.equals(maturityDateBefore, this.maturityDate)) {
|
||||
actualChanges.put("maturityDate", this.maturityDate);
|
||||
}
|
||||
if (maturityAmountBefore == null || this.maturityAmount.compareTo(maturityAmountBefore) != 0) {
|
||||
actualChanges.put("maturityAmount", this.maturityAmount);
|
||||
}
|
||||
|
||||
return actualChanges;
|
||||
}
|
||||
|
||||
|
||||
+67
-9
@@ -23,20 +23,25 @@ import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Getter;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
|
||||
/**
|
||||
* A single nominee recorded against a {@link Party}. Maker-checker on nominee edits is enforced at
|
||||
* the command layer (the whole Party update goes through the standard maker-checker command flow),
|
||||
* not on this child row individually.
|
||||
*
|
||||
* Audited in its own right rather than only through the party: this row decides who receives a
|
||||
* customer's money after them, and "the party changed at 16:19" does not say that the person named
|
||||
* to receive it was replaced, nor by whom.
|
||||
*/
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "m_dds_party_nominee")
|
||||
public class PartyNominee extends AbstractPersistableCustom<Long> {
|
||||
public class PartyNominee extends AbstractAuditableCustom {
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "party_id", nullable = false)
|
||||
@@ -54,9 +59,24 @@ public class PartyNominee extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "birth_date")
|
||||
private LocalDate birthDate;
|
||||
|
||||
/**
|
||||
* Whether this nominee was under age when nominated, worked out from their date of birth rather
|
||||
* than asked for.
|
||||
*
|
||||
* Asked for, it was got wrong: a ten-year-old was recorded as not a minor, and so no guardian
|
||||
* was required for them. The date of birth is captured either way and already holds the answer,
|
||||
* so the clerk is not asked a second time in a form that can disagree with the first.
|
||||
*
|
||||
* Kept as a stored column rather than worked out on reading, because it is the age at the
|
||||
* moment of nomination that the record is of - a nominee who has since come of age was still a
|
||||
* minor on the day they were named, and the guardian named beside them belongs to that day.
|
||||
*/
|
||||
@Column(name = "minor", nullable = false)
|
||||
private boolean minor;
|
||||
|
||||
/** India's age of majority, which is what makes a nominee one who needs a guardian named. */
|
||||
private static final int AGE_OF_MAJORITY = 18;
|
||||
|
||||
@Column(name = "relation", length = 40, nullable = false)
|
||||
private String relation;
|
||||
|
||||
@@ -66,23 +86,61 @@ public class PartyNominee extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "guardian_name", length = 100)
|
||||
private String guardianName;
|
||||
|
||||
/**
|
||||
* What this nominee is entitled to, under a Percentage split. Null under a Sequence split, where
|
||||
* the order the nominees are held in is the entitlement and there is no share to state.
|
||||
*/
|
||||
@Column(name = "share_percent", precision = 5, scale = 2)
|
||||
private BigDecimal sharePercent;
|
||||
|
||||
/**
|
||||
* Where this nominee stands in the order the bank pays, under a Sequence split. Null under a
|
||||
* Percentage split, where everyone is paid at once and there is no first.
|
||||
*/
|
||||
@Column(name = "sequence_no")
|
||||
private Integer sequenceNo;
|
||||
|
||||
protected PartyNominee() {
|
||||
//
|
||||
}
|
||||
|
||||
public PartyNominee(final LocalDate nominationDate, final String name, final String mobileNo, final LocalDate birthDate,
|
||||
final boolean minor, final String relation, final String gender, final String guardianName) {
|
||||
final String relation, final String gender, final String guardianName, final BigDecimal sharePercent,
|
||||
final Integer sequenceNo) {
|
||||
this.nominationDate = nominationDate;
|
||||
this.name = name;
|
||||
this.mobileNo = StringUtils.defaultIfEmpty(mobileNo, null);
|
||||
// Trimmed on the way in. A relation typed as "friend " is a different value from "friend"
|
||||
// to everything that groups or matches on it, and the trailing space is invisible on screen
|
||||
// - so the two read as the same relation everywhere except where it counts.
|
||||
this.name = StringUtils.trimToNull(name);
|
||||
this.mobileNo = StringUtils.trimToNull(mobileNo);
|
||||
this.birthDate = birthDate;
|
||||
this.minor = minor;
|
||||
this.relation = relation;
|
||||
this.gender = gender;
|
||||
this.guardianName = StringUtils.defaultIfEmpty(guardianName, null);
|
||||
this.minor = isUnderAgeOn(birthDate, nominationDate);
|
||||
this.relation = StringUtils.trimToNull(relation);
|
||||
this.gender = StringUtils.trimToNull(gender);
|
||||
this.guardianName = StringUtils.trimToNull(guardianName);
|
||||
this.sharePercent = sharePercent;
|
||||
this.sequenceNo = sequenceNo;
|
||||
}
|
||||
|
||||
void setParty(final Party party) {
|
||||
this.party = party;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether someone born on the given day was still under age on the day they were nominated.
|
||||
*
|
||||
* Measured against the nomination date rather than today, so a nomination reads the same in ten
|
||||
* years as it did when it was made - and a nominee who has since come of age does not quietly
|
||||
* lose the guardian who was named for them at the time.
|
||||
*
|
||||
* A missing date of birth is treated as not a minor: the field is required, so a null here can
|
||||
* only be a record made before it was, and guessing "minor" would demand a guardian for someone
|
||||
* who may well be eighty.
|
||||
*/
|
||||
public static boolean isUnderAgeOn(final LocalDate birthDate, final LocalDate nominationDate) {
|
||||
if (birthDate == null || nominationDate == null) {
|
||||
return false;
|
||||
}
|
||||
return birthDate.isAfter(nominationDate.minusYears(AGE_OF_MAJORITY));
|
||||
}
|
||||
}
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a Party's subType is not the code of an active DDS Account Type.
|
||||
*
|
||||
* The Party screen's Sub Type is chosen from the DDS Account Type master, so an unknown code means
|
||||
* the caller invented one, and a deactivated code means the scheme has been retired - existing
|
||||
* parties on it keep running, but no new party may be opened under it.
|
||||
*/
|
||||
public class PartySubTypeNotActiveException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartySubTypeNotActiveException(final String subType, final boolean exists) {
|
||||
super(exists ? "error.msg.party.sub.type.not.active" : "error.msg.party.sub.type.invalid",
|
||||
exists ? "Account type " + subType + " has been deactivated, so no new party can be opened under it"
|
||||
: "There is no DDS account type with the code " + subType,
|
||||
subType);
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.transaction.domain;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* How money changed hands on a DDS entry: over the counter, by book transfer, or through clearing.
|
||||
*
|
||||
* It exists because the mode used to be a free-text field. The screen only ever offered these three,
|
||||
* but nothing behind the screen said so, and the column filled up with two spellings of the same
|
||||
* word - `Cash` from one path and `CASH` from another. A cash book that groups by mode then reports
|
||||
* the same day's takings twice under two headings, which is the kind of discrepancy someone spends
|
||||
* an afternoon chasing before finding it was never a discrepancy at all.
|
||||
*
|
||||
* So the list lives here, once, and both the validator and the write service read it from here. What
|
||||
* a caller sends is matched without regard to case or surrounding space, because an integration
|
||||
* sending `cash` is unambiguous and rejecting it would be pedantry; but what gets stored is always
|
||||
* the label below, so the column only ever holds one spelling of each.
|
||||
*
|
||||
* Only entries a person makes carry a mode. Interest, commission and maturity postings are the
|
||||
* system's own work - no cash, no cheque, nothing to record - and a transfer between two DDS accounts
|
||||
* moves nothing across the counter either, so those are left null rather than labelled.
|
||||
*/
|
||||
public enum DdsTransactionMode {
|
||||
|
||||
CASH("Cash"), //
|
||||
TRANSFER("Transfer"), //
|
||||
CLEARING("Clearing");
|
||||
|
||||
private final String label;
|
||||
|
||||
DdsTransactionMode(final String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
/** The one spelling that is ever stored or shown for this mode. */
|
||||
public String getLabel() {
|
||||
return this.label;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mode a caller's text names, or null if it names none of them. Case and surrounding space
|
||||
* are ignored; anything else is a mode this scheme does not have.
|
||||
*/
|
||||
public static DdsTransactionMode fromLabel(final String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
final String trimmed = value.trim();
|
||||
return Arrays.stream(values()).filter(mode -> mode.label.equalsIgnoreCase(trimmed)).findFirst().orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored form of what a caller sent: the canonical label when it names a mode, and the value
|
||||
* untouched when it does not.
|
||||
*
|
||||
* Unknown text is passed through rather than blanked because this is called after validation has
|
||||
* already refused it - a silent blanking here would only hide a bug in the order of the two.
|
||||
*/
|
||||
public static String canonicalOrOriginal(final String value) {
|
||||
final DdsTransactionMode mode = fromLabel(value);
|
||||
return mode == null ? value : mode.label;
|
||||
}
|
||||
|
||||
/** The labels, in the order the entry screen lists them - for error messages and for callers. */
|
||||
public static List<String> labels() {
|
||||
return Arrays.stream(values()).map(DdsTransactionMode::getLabel).toList();
|
||||
}
|
||||
|
||||
/** The labels as a reader sees them in a sentence: "Cash, Transfer, Clearing". */
|
||||
public static String labelsForMessage() {
|
||||
return String.join(", ", labels());
|
||||
}
|
||||
|
||||
/**
|
||||
* The same rule as {@link #fromLabel}, expressed as a regular expression, for the validator to
|
||||
* apply - it reports a failed pattern with the parameter and message already filled in, which a
|
||||
* hand-rolled check cannot do as tidily.
|
||||
*
|
||||
* Built from the labels rather than written out, so the two ways of asking the same question
|
||||
* cannot answer it differently: add a mode above and both move together.
|
||||
*/
|
||||
public static String acceptedPattern() {
|
||||
return Arrays.stream(values()).map(mode -> Pattern.quote(mode.label)).collect(Collectors.joining("|", "(?i)\\s*(", ")\\s*"));
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -26,7 +26,7 @@ import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||
|
||||
@@ -42,7 +42,7 @@ import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "m_dds_party_transaction")
|
||||
public class PartyTransaction extends AbstractPersistableCustom<Long> {
|
||||
public class PartyTransaction extends AbstractAuditableCustom {
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "party_id")
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.transaction.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a transaction is posted against an agent who does not collect for the party named on
|
||||
* it.
|
||||
*
|
||||
* The balances would still come out right, but the transaction would be filed under the wrong
|
||||
* agent - and every agent-wise report is built from exactly that. An agent would show collections
|
||||
* and transfers for customers who are not theirs, and the officer reading the report has no way to
|
||||
* tell.
|
||||
*/
|
||||
public class PartyNotCollectedByAgentException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyNotCollectedByAgentException(final String partyCode, final String agentCode) {
|
||||
super("error.msg.party.not.collected.by.agent", "Party " + partyCode + " is not collected by agent " + agentCode,
|
||||
partyCode, agentCode);
|
||||
}
|
||||
}
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.domain;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import org.apache.fineract.organisation.office.domain.Office;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.dds.DdsCommands;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
/**
|
||||
* What a collection agent is holding and what they are owed.
|
||||
*
|
||||
* Two separate pots, and keeping them separate is the point: the cash balance is the bank's money
|
||||
* sitting in the agent's bag, and the commission is the agent's own earnings. Mixing them up would
|
||||
* either lose the bank's cash or pay an agent twice, so each of these checks one and leaves the
|
||||
* other alone.
|
||||
*/
|
||||
class AgentTest {
|
||||
|
||||
private Agent agent(final String extraJson) {
|
||||
final Client client = Mockito.mock(Client.class);
|
||||
final String json = """
|
||||
{
|
||||
"clientId": 1, "agentNameId": "Test Agent", "agentAddress1": "Somewhere",
|
||||
"agentCity": "Bhopal", "agentPinCode": "462016",
|
||||
"agentSavingAccountType": "SD Product", "agentSavingAccountNo": "002002000100011",
|
||||
"agentOpeningDate": "01 September 2026", "agentBirthDate": "01 January 1990",
|
||||
"dateFormat": "dd MMMM yyyy", "locale": "en"
|
||||
%s
|
||||
}
|
||||
""".formatted(extraJson);
|
||||
return Agent.fromJson(client, "AG0001", office("Head Office"), DdsCommands.of(json));
|
||||
}
|
||||
|
||||
private Office office(final String name) {
|
||||
final Office office = Mockito.mock(Office.class);
|
||||
Mockito.lenient().when(office.getName()).thenReturn(name);
|
||||
return office;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Cash the agent is carrying")
|
||||
class Cash {
|
||||
|
||||
@Test
|
||||
void aCollectionRaisesTheCashTheAgentHolds() {
|
||||
final Agent agent = agent("");
|
||||
|
||||
assertEquals(new BigDecimal("100"), agent.credit(new BigDecimal("100")));
|
||||
assertEquals(new BigDecimal("400"), agent.credit(new BigDecimal("300")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void settlingAtABranchLowersIt() {
|
||||
final Agent agent = agent("");
|
||||
agent.credit(new BigDecimal("500"));
|
||||
|
||||
assertEquals(new BigDecimal("200"), agent.settle(new BigDecimal("300")));
|
||||
}
|
||||
|
||||
/** What the Close Account guard reads: money still out with an agent stops them being closed. */
|
||||
@Test
|
||||
void anAgentHoldsCashUntilEverythingIsSettled() {
|
||||
final Agent agent = agent("");
|
||||
assertFalse(agent.holdsCash());
|
||||
|
||||
agent.credit(new BigDecimal("500"));
|
||||
assertTrue(agent.holdsCash());
|
||||
|
||||
agent.settle(new BigDecimal("500"));
|
||||
assertFalse(agent.holdsCash());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anOpeningBalanceIsCashTheAgentAlreadyHad() {
|
||||
final Agent agent = agent(", \"agentOpeningBalance\": 750");
|
||||
|
||||
assertEquals(new BigDecimal("750"), agent.getAgentCurrentBalance());
|
||||
assertEquals(new BigDecimal("750"), agent.getAgentOpeningBalance());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Commission the agent has earned")
|
||||
class Commission {
|
||||
|
||||
private static final LocalDate THROUGH = LocalDate.of(2026, 9, 30);
|
||||
|
||||
/**
|
||||
* Accruing records what is owed and the TDS withheld from it, and stamps how far commission
|
||||
* has been worked out - the stamp being what stops the same period being paid twice.
|
||||
*/
|
||||
@Test
|
||||
void accruingRecordsWhatIsOwedAndTheTaxWithheld() {
|
||||
final Agent agent = agent("");
|
||||
|
||||
agent.accrueCommission(new BigDecimal("13.50"), new BigDecimal("1.50"), THROUGH);
|
||||
|
||||
assertEquals(new BigDecimal("13.50"), agent.getCommission());
|
||||
assertEquals(new BigDecimal("1.50"), agent.getTdsAmount());
|
||||
assertEquals(THROUGH, agent.getLastCommissionDate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void payingItOutLeavesNothingOwed() {
|
||||
final Agent agent = agent("");
|
||||
agent.accrueCommission(new BigDecimal("15.00"), BigDecimal.ZERO, THROUGH);
|
||||
|
||||
assertEquals(new BigDecimal("0.00"), agent.payOutCommission(new BigDecimal("15.00")));
|
||||
assertFalse(agent.isOwedCommission());
|
||||
}
|
||||
|
||||
/** The guard against paying twice: a second payout comes off a figure already at zero. */
|
||||
@Test
|
||||
void aSecondPayoutOfTheSameCommissionFindsNothingLeft() {
|
||||
final Agent agent = agent("");
|
||||
agent.accrueCommission(new BigDecimal("15.00"), BigDecimal.ZERO, THROUGH);
|
||||
agent.payOutCommission(new BigDecimal("15.00"));
|
||||
|
||||
assertEquals(new BigDecimal("0.00"), agent.getCommission());
|
||||
}
|
||||
|
||||
/** Commission is the agent's own earnings, so paying it must not touch the bank's cash. */
|
||||
@Test
|
||||
void payingCommissionDoesNotTouchTheCashTheAgentIsCarrying() {
|
||||
final Agent agent = agent("");
|
||||
agent.credit(new BigDecimal("10000"));
|
||||
agent.accrueCommission(new BigDecimal("15.00"), BigDecimal.ZERO, THROUGH);
|
||||
|
||||
agent.payOutCommission(new BigDecimal("15.00"));
|
||||
|
||||
assertEquals(new BigDecimal("10000"), agent.getAgentCurrentBalance());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAgentIsOwedCommissionUntilItIsPaid() {
|
||||
final Agent agent = agent("");
|
||||
assertFalse(agent.isOwedCommission());
|
||||
|
||||
agent.accrueCommission(new BigDecimal("15.00"), BigDecimal.ZERO, THROUGH);
|
||||
assertTrue(agent.isOwedCommission());
|
||||
}
|
||||
|
||||
/**
|
||||
* The four standing amounts the Agent Commission Statement subtracts from gross commission.
|
||||
*
|
||||
* Set by editing an agent, not by creating one - they are figures the bank agrees with an
|
||||
* agent after the fact, so a create request carrying them is ignored rather than trusted.
|
||||
*/
|
||||
@Test
|
||||
void deductionsAreTheSumOfTheFourStandingAmounts() {
|
||||
final Agent agent = agent("");
|
||||
|
||||
agent.update(DdsCommands.of("""
|
||||
{"deductCommission": 1, "agentDeposit": 2, "festivalAdvance": 3, "otherDeduction": 4,
|
||||
"dateFormat": "dd MMMM yyyy", "locale": "en"}
|
||||
"""));
|
||||
|
||||
assertEquals(new BigDecimal("10"), agent.totalDeductions());
|
||||
}
|
||||
|
||||
/** A create request cannot set them, whatever it sends - they are agreed later, not at opening. */
|
||||
@Test
|
||||
void deductionsAreNotTakenFromACreateRequest() {
|
||||
final Agent agent = agent(", \"deductCommission\": 1, \"agentDeposit\": 2,"
|
||||
+ " \"festivalAdvance\": 3, \"otherDeduction\": 4");
|
||||
|
||||
assertEquals(0, agent.totalDeductions().compareTo(BigDecimal.ZERO));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAgentWithNoDeductionsSubtractsNothing() {
|
||||
final Agent agent = agent("");
|
||||
|
||||
assertEquals(0, agent.totalDeductions().compareTo(BigDecimal.ZERO));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Which branch the agent belongs to")
|
||||
class Branch {
|
||||
|
||||
/**
|
||||
* The branch name is written from the office rather than set on its own, so the two cannot
|
||||
* disagree - a renamed branch reads correctly without the agent being touched.
|
||||
*/
|
||||
@Test
|
||||
void theBranchNameComesFromTheOffice() {
|
||||
final Agent agent = agent("");
|
||||
|
||||
assertEquals("Head Office", agent.getHomeBranch());
|
||||
|
||||
agent.setOffice(office("Borivali"));
|
||||
|
||||
assertEquals("Borivali", agent.getHomeBranch());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAgentWithNoOfficeHasNoBranchName() {
|
||||
final Agent agent = agent("");
|
||||
|
||||
agent.setOffice(null);
|
||||
|
||||
assertNull(agent.getHomeBranch());
|
||||
assertNull(agent.officeId());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Closing")
|
||||
class Closing {
|
||||
|
||||
@Test
|
||||
void closingAlsoStopsTheAgentCollecting() {
|
||||
final Agent agent = agent("");
|
||||
assertFalse(agent.isClosed());
|
||||
assertTrue(agent.isActive());
|
||||
|
||||
agent.close(LocalDate.of(2026, 12, 1));
|
||||
|
||||
assertTrue(agent.isClosed());
|
||||
assertFalse(agent.isActive());
|
||||
assertEquals(LocalDate.of(2026, 12, 1), agent.getCloseDate());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
|
||||
/**
|
||||
* Builds the {@link JsonCommand} a DDS entity is created from, so a test can make a real Party or
|
||||
* Agent the same way the API does rather than reaching into their fields.
|
||||
*
|
||||
* The alternative - setting fields by reflection - would test a state the application can never
|
||||
* actually produce, which is the opposite of useful.
|
||||
*/
|
||||
public final class DdsCommands {
|
||||
|
||||
private static final FromJsonHelper JSON_HELPER = new FromJsonHelper();
|
||||
|
||||
private DdsCommands() {}
|
||||
|
||||
/**
|
||||
* A command carrying the given JSON. Dates are read with the {@code dateFormat} and {@code locale}
|
||||
* the caller puts in that JSON, exactly as a request does.
|
||||
*/
|
||||
public static JsonCommand of(final String json) {
|
||||
final JsonElement parsed = JSON_HELPER.parse(json);
|
||||
return JsonCommand.from(json, parsed, JSON_HELPER, null, null, null, null, null, null, null, null, null, null, null, null, null,
|
||||
null);
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.calculation;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* What an agent is paid on a period's collections.
|
||||
*
|
||||
* The first two figures here are the ones the live system actually produced - ₹500 collected at 3%
|
||||
* paid ₹15, and ₹1,500 paid ₹45 - so if the formula ever moves, these fail rather than the change
|
||||
* going out unnoticed.
|
||||
*/
|
||||
class DdsCommissionCalculationTest {
|
||||
|
||||
private static final BigDecimal THREE_PERCENT = new BigDecimal("3");
|
||||
private static final BigDecimal NONE = BigDecimal.ZERO;
|
||||
|
||||
@Test
|
||||
void threePercentOfFiveHundredIsFifteen() {
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(new BigDecimal("500"), THREE_PERCENT, false, null, NONE);
|
||||
|
||||
assertEquals(new BigDecimal("15.00"), commission.gross());
|
||||
assertEquals(new BigDecimal("15.00"), commission.net());
|
||||
assertTrue(commission.isPayable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void threePercentOfFifteenHundredIsFortyFive() {
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(new BigDecimal("1500"), THREE_PERCENT, false, null, NONE);
|
||||
|
||||
assertEquals(new BigDecimal("45.00"), commission.net());
|
||||
}
|
||||
|
||||
/** Tax comes off the gross, not off the collections. */
|
||||
@Test
|
||||
void taxIsWithheldFromTheGrossWhenTheAgentIsLiable() {
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(new BigDecimal("1000"), THREE_PERCENT, true,
|
||||
new BigDecimal("10"), NONE);
|
||||
|
||||
assertEquals(new BigDecimal("30.00"), commission.gross());
|
||||
assertEquals(new BigDecimal("3.00"), commission.tds());
|
||||
assertEquals(new BigDecimal("27.00"), commission.net());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noTaxIsWithheldFromAnAgentWhoIsNotLiable() {
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(new BigDecimal("1000"), THREE_PERCENT, false,
|
||||
new BigDecimal("10"), NONE);
|
||||
|
||||
assertEquals(BigDecimal.ZERO, commission.tds());
|
||||
assertEquals(new BigDecimal("30.00"), commission.net());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theBankRecoversItsStandingDeductionsFromWhatIsLeft() {
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(new BigDecimal("1000"), THREE_PERCENT, true,
|
||||
new BigDecimal("10"), new BigDecimal("7"));
|
||||
|
||||
assertEquals(new BigDecimal("30.00"), commission.gross());
|
||||
assertEquals(new BigDecimal("3.00"), commission.tds());
|
||||
assertEquals(new BigDecimal("7"), commission.deductions());
|
||||
assertEquals(new BigDecimal("20.00"), commission.net());
|
||||
}
|
||||
|
||||
/**
|
||||
* The deductions can swallow the whole period. Nothing is owed, and the agent is not put into
|
||||
* debt over it - so the run pays nothing rather than carrying a negative figure forward.
|
||||
*/
|
||||
@Test
|
||||
void deductionsLargerThanTheCommissionLeaveNothingPayable() {
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(new BigDecimal("500"), THREE_PERCENT, false, null,
|
||||
new BigDecimal("50"));
|
||||
|
||||
assertEquals(new BigDecimal("15.00"), commission.gross());
|
||||
assertEquals(new BigDecimal("-35.00"), commission.net());
|
||||
assertFalse(commission.isPayable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deductionsExactlyEqualToTheCommissionLeaveNothingPayable() {
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(new BigDecimal("500"), THREE_PERCENT, false, null,
|
||||
new BigDecimal("15"));
|
||||
|
||||
assertEquals(new BigDecimal("0.00"), commission.net());
|
||||
assertFalse(commission.isPayable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAgentWhoCollectedNothingEarnsNothing() {
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(BigDecimal.ZERO, THREE_PERCENT, false, null, NONE);
|
||||
|
||||
assertEquals(new BigDecimal("0.00"), commission.net());
|
||||
assertFalse(commission.isPayable());
|
||||
}
|
||||
|
||||
/** Paise are rounded half-up, so a fraction of a paisa never goes missing or gets invented. */
|
||||
@Test
|
||||
void grossIsRoundedToPaise() {
|
||||
// 3.5% of 333 = 11.655, which rounds to 11.66 rather than being truncated to 11.65.
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(new BigDecimal("333"), new BigDecimal("3.5"), false, null,
|
||||
NONE);
|
||||
|
||||
assertEquals(new BigDecimal("11.66"), commission.gross());
|
||||
}
|
||||
|
||||
/** Everything is kept, not just the net - an agent asking why their pay was short needs it all. */
|
||||
@Test
|
||||
void theWholeBreakdownIsKept() {
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(new BigDecimal("2000"), new BigDecimal("5"), true,
|
||||
new BigDecimal("10"), new BigDecimal("25"));
|
||||
|
||||
assertEquals(new BigDecimal("2000"), commission.collectionVolume());
|
||||
assertEquals(new BigDecimal("5"), commission.ratePercent());
|
||||
assertEquals(new BigDecimal("100.00"), commission.gross());
|
||||
assertEquals(new BigDecimal("10.00"), commission.tds());
|
||||
assertEquals(new BigDecimal("25"), commission.deductions());
|
||||
assertEquals(new BigDecimal("65.00"), commission.net());
|
||||
}
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.calculation;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.apache.fineract.portfolio.dds.calculation.DdsInterestCalculation.Movement;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* What a DDS account earns over a period.
|
||||
*
|
||||
* Interest is on the daily product - the balance summed over the days it was actually held - which
|
||||
* is what makes a daily-deposit account different from an ordinary one. Paying on the closing
|
||||
* balance for the whole period instead was the bug this replaced, and it paid customers roughly
|
||||
* double what they were owed, so the sums below are worth pinning down exactly.
|
||||
*/
|
||||
class DdsInterestCalculationTest {
|
||||
|
||||
private static final BigDecimal TWO_PERCENT = new BigDecimal("2");
|
||||
private static final LocalDate SEP_1 = LocalDate.of(2026, 9, 1);
|
||||
private static final LocalDate SEP_30 = LocalDate.of(2026, 9, 30);
|
||||
|
||||
private static Movement in(final int day, final String amount) {
|
||||
return new Movement(LocalDate.of(2026, 9, day), new BigDecimal(amount));
|
||||
}
|
||||
|
||||
private static Movement out(final int day, final String amount) {
|
||||
return new Movement(LocalDate.of(2026, 9, day), new BigDecimal(amount).negate());
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("The balance summed over the days it was held")
|
||||
class DailyProduct {
|
||||
|
||||
/** An untouched account is simply its balance for every day of the period. */
|
||||
@Test
|
||||
void anUntouchedBalanceEarnsForEveryDay() {
|
||||
final BigDecimal product = DdsInterestCalculation.dailyProduct(new BigDecimal("1000"), List.of(), SEP_1, SEP_30);
|
||||
|
||||
// 29 days from the 1st to the 30th, at 1,000.
|
||||
assertEquals(new BigDecimal("29000"), product);
|
||||
}
|
||||
|
||||
/** A deposit earns from the day it was made, not the day after. */
|
||||
@Test
|
||||
void aDepositEarnsFromTheDayItWasMade() {
|
||||
final BigDecimal product = DdsInterestCalculation.dailyProduct(BigDecimal.ZERO, List.of(in(30, "100")), SEP_1, SEP_30);
|
||||
|
||||
// Nothing held until the 30th, then 100 for that one day.
|
||||
assertEquals(new BigDecimal("100"), product);
|
||||
}
|
||||
|
||||
@Test
|
||||
void eachDepositEarnsOnlyForTheDaysAfterIt() {
|
||||
final BigDecimal product = DdsInterestCalculation.dailyProduct(BigDecimal.ZERO,
|
||||
List.of(in(1, "100"), in(11, "100"), in(21, "100")), SEP_1, SEP_30);
|
||||
|
||||
// The 1st's 100 is in the opening balance (dated on the from date), so it earns 29 days.
|
||||
// 200 from the 11th to the 20th is 10 days; 300 from the 21st to the 30th is 10 days.
|
||||
// 100x29 + ... - worked through: 2900 + ... see the assertion.
|
||||
assertEquals(new BigDecimal("5900"), product);
|
||||
}
|
||||
|
||||
/** A payout stops earning from the day it is taken. */
|
||||
@Test
|
||||
void aPayoutStopsEarningFromThatDay() {
|
||||
final BigDecimal product = DdsInterestCalculation.dailyProduct(new BigDecimal("1000"), List.of(out(11, "500")), SEP_1, SEP_30);
|
||||
|
||||
// 1,000 for the 2nd to the 10th (9 days), then 500 from the 11th to the 30th (20 days).
|
||||
assertEquals(new BigDecimal("19000"), product);
|
||||
}
|
||||
|
||||
/** Everything dated on or before the start is the balance the period opens with. */
|
||||
@Test
|
||||
void movementsBeforeThePeriodMakeUpTheOpeningBalance() {
|
||||
final BigDecimal product = DdsInterestCalculation.dailyProduct(BigDecimal.ZERO,
|
||||
List.of(new Movement(LocalDate.of(2026, 8, 15), new BigDecimal("500")), in(1, "500")), SEP_1, SEP_30);
|
||||
|
||||
// Both are on or before the 1st, so the period opens at 1,000 and nothing moves after.
|
||||
assertEquals(new BigDecimal("29000"), product);
|
||||
}
|
||||
|
||||
/** A movement after the period has not happened yet as far as this run is concerned. */
|
||||
@Test
|
||||
void movementsAfterThePeriodAreIgnored() {
|
||||
final BigDecimal product = DdsInterestCalculation.dailyProduct(new BigDecimal("1000"),
|
||||
List.of(new Movement(LocalDate.of(2026, 10, 5), new BigDecimal("9999"))), SEP_1, SEP_30);
|
||||
|
||||
assertEquals(new BigDecimal("29000"), product);
|
||||
}
|
||||
|
||||
/** Several movements on one day all land before the day is counted. */
|
||||
@Test
|
||||
void everythingOnOneDayIsAppliedBeforeThatDayIsCounted() {
|
||||
final BigDecimal product = DdsInterestCalculation.dailyProduct(BigDecimal.ZERO,
|
||||
List.of(in(30, "100"), in(30, "100"), in(30, "100")), SEP_1, SEP_30);
|
||||
|
||||
assertEquals(new BigDecimal("300"), product);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anEmptyAccountEarnsNothing() {
|
||||
assertEquals(BigDecimal.ZERO, DdsInterestCalculation.dailyProduct(BigDecimal.ZERO, List.of(), SEP_1, SEP_30));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPeriodOfNoDaysEarnsNothing() {
|
||||
assertEquals(BigDecimal.ZERO, DdsInterestCalculation.dailyProduct(new BigDecimal("1000"), List.of(), SEP_1, SEP_1));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Interest on the product")
|
||||
class Interest {
|
||||
|
||||
/**
|
||||
* The figures the live system produced and that were checked by hand: ₹10,000 held through a
|
||||
* month at 2% earned ₹52.60, and the same balance plus a month's interest earned ₹60.92 over
|
||||
* a longer stretch. Nothing in the sum may move these.
|
||||
*/
|
||||
@Test
|
||||
void tenThousandThroughSeptemberAtTwoPercentEarnsFiftyTwoSixty() {
|
||||
final BigDecimal product = DdsInterestCalculation.dailyProduct(new BigDecimal("10000"), List.of(),
|
||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 9, 30));
|
||||
|
||||
// 60 days at 10,000 = 600,000. 600,000 x 2 / 100 / 365 = 32.876...
|
||||
assertEquals(new BigDecimal("600000"), product);
|
||||
assertEquals(new BigDecimal("32.88"), DdsInterestCalculation.interestOn(product, TWO_PERCENT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aKnownProductGivesAKnownInterest() {
|
||||
// 960,000 x 2 / 100 / 365 = 52.6027... which is the 52.60 the live run posted.
|
||||
assertEquals(new BigDecimal("52.60"), DdsInterestCalculation.interestOn(new BigDecimal("960000"), TWO_PERCENT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aZeroRateEarnsNothing() {
|
||||
assertEquals(new BigDecimal("0.00"), DdsInterestCalculation.interestOn(new BigDecimal("29000"), BigDecimal.ZERO));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aZeroProductEarnsNothing() {
|
||||
assertEquals(new BigDecimal("0.00"), DdsInterestCalculation.interestOn(BigDecimal.ZERO, TWO_PERCENT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void interestIsRoundedToPaise() {
|
||||
// 1,000 x 2 / 100 / 365 = 0.0547... which rounds to 0.05.
|
||||
assertEquals(new BigDecimal("0.05"), DdsInterestCalculation.interestOn(new BigDecimal("1000"), TWO_PERCENT));
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole point of the daily product: an account paid into daily earns far less than the
|
||||
* same closing balance held all period, because most of the money arrived late.
|
||||
*/
|
||||
@Test
|
||||
void payingInDailyEarnsFarLessThanHoldingTheSameBalanceAllPeriod() {
|
||||
final List<Movement> daily = List.of(in(1, "100"), in(2, "100"), in(3, "100"), in(4, "100"), in(5, "100"));
|
||||
|
||||
final BigDecimal paidInDaily = DdsInterestCalculation.dailyProduct(BigDecimal.ZERO, daily, SEP_1,
|
||||
LocalDate.of(2026, 9, 5));
|
||||
final BigDecimal heldThroughout = DdsInterestCalculation.dailyProduct(new BigDecimal("500"), List.of(), SEP_1,
|
||||
LocalDate.of(2026, 9, 5));
|
||||
|
||||
// Paid in daily: 200 on the 2nd, 300 on the 3rd, 400 on the 4th, 500 on the 5th. The
|
||||
// 1st's instalment is dated on the from date, so it makes up the opening balance rather
|
||||
// than earning a day of its own.
|
||||
assertEquals(new BigDecimal("1400"), paidInDaily);
|
||||
|
||||
// The same 500 held from the start earns four full days on the whole amount.
|
||||
assertEquals(new BigDecimal("2000"), heldThroughout);
|
||||
}
|
||||
}
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.domain;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.dds.DdsCommands;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
/**
|
||||
* What a DDS customer's account owes them, and how it changes.
|
||||
*
|
||||
* Every figure here is money a customer is owed, so each of these is a sum that has to come out
|
||||
* exactly - not approximately. The maturity figures are the ones quoted to the customer when the
|
||||
* account is opened, and the interest ones were verified by hand against the live system before
|
||||
* being written down here, so a change that moves them will fail rather than pass quietly.
|
||||
*/
|
||||
class PartyTest {
|
||||
|
||||
private static final BigDecimal HUNDRED_A_DAY = new BigDecimal("100");
|
||||
|
||||
private Party party(final String extraJson) {
|
||||
final Client client = Mockito.mock(Client.class);
|
||||
final Agent agent = Mockito.mock(Agent.class);
|
||||
final String json = """
|
||||
{
|
||||
"clientId": 1, "agentId": 1, "subType": "DDS",
|
||||
"partyName": "Test Customer", "address1": "Somewhere", "city": "Bhopal",
|
||||
"pincode": "462016", "mobileNoSms": "9000000000", "operation": "Self",
|
||||
"adultMinor": "Adult", "openingDate": "01 September 2026",
|
||||
"dateFormat": "dd MMMM yyyy", "locale": "en"
|
||||
%s
|
||||
}
|
||||
""".formatted(extraJson);
|
||||
return Party.fromJson(client, agent, "PT0001", DdsCommands.of(json));
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("What the customer is quoted at opening")
|
||||
class Maturity {
|
||||
|
||||
/**
|
||||
* A day's deposit earns interest for every day it is held, so the first instalment earns for
|
||||
* the whole term and the last for one day - n(n+1)/2 instalment-days in total. This is the
|
||||
* figure quoted to the customer, and the one the interest runs have to add up to.
|
||||
*/
|
||||
@Test
|
||||
void interestIsOnEachInstalmentForTheDaysItIsHeld() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90, \"interestRate\": 2");
|
||||
|
||||
// 90 x 100 = 9,000 principal. Instalment-days = 90 x 91 / 2 = 4,095.
|
||||
// Interest = 100 x 4095 x 2 / 100 / 365 = 22.4383...
|
||||
assertEquals(new BigDecimal("9022.44"), party.getMaturityAmount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aZeroRateReturnsTheDepositsAndNothingMore() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90, \"interestRate\": 0");
|
||||
|
||||
assertEquals(new BigDecimal("9000.00"), party.getMaturityAmount());
|
||||
}
|
||||
|
||||
/** Nothing is quoted for a term nobody has agreed to yet. */
|
||||
@Test
|
||||
void noTermMeansNoMaturityAmount() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 0, \"interestRate\": 2");
|
||||
|
||||
assertEquals(BigDecimal.ZERO, party.getMaturityAmount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noDailyAmountMeansNoMaturityAmount() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 0, \"days\": 90, \"interestRate\": 2");
|
||||
|
||||
assertEquals(BigDecimal.ZERO, party.getMaturityAmount());
|
||||
}
|
||||
|
||||
/** The rate the business settled on when none is sent - see Party's DEFAULT_INTEREST_RATE. */
|
||||
@Test
|
||||
void aRateIsAppliedWhenNoneWasSent() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90");
|
||||
|
||||
assertEquals(new BigDecimal("2"), party.getInterestRate());
|
||||
assertEquals(new BigDecimal("9022.44"), party.getMaturityAmount());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Collections and payouts")
|
||||
class Balance {
|
||||
|
||||
@Test
|
||||
void aCollectionRaisesTheBalanceAndReturnsIt() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90");
|
||||
|
||||
assertEquals(new BigDecimal("100"), party.credit(HUNDRED_A_DAY));
|
||||
assertEquals(new BigDecimal("200"), party.credit(HUNDRED_A_DAY));
|
||||
assertEquals(new BigDecimal("200"), party.getCurrentBalance());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPayoutLowersTheBalance() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90");
|
||||
party.credit(new BigDecimal("500"));
|
||||
|
||||
assertEquals(new BigDecimal("200"), party.debit(new BigDecimal("300")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anOpeningBalanceStartsTheAccountOff() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90, \"openingBalance\": 250");
|
||||
|
||||
assertEquals(new BigDecimal("250"), party.getCurrentBalance());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Interest, and why it cannot be earned twice")
|
||||
class Interest {
|
||||
|
||||
private static final LocalDate THROUGH = LocalDate.of(2026, 9, 30);
|
||||
|
||||
/** Accrued interest is not the customer's to draw on, so the balance does not move yet. */
|
||||
@Test
|
||||
void accruingParksTheInterestWithoutTouchingTheBalance() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90");
|
||||
party.credit(new BigDecimal("1000"));
|
||||
|
||||
party.accrueInterest(new BigDecimal("52.60"), THROUGH, false);
|
||||
|
||||
assertEquals(new BigDecimal("52.60"), party.accruedInterest(false));
|
||||
assertEquals(new BigDecimal("1000"), party.getCurrentBalance());
|
||||
assertEquals(THROUGH, party.interestCalculatedThrough(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void postingMovesItIntoTheBalanceAndLeavesNothingAccrued() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90");
|
||||
party.credit(new BigDecimal("1000"));
|
||||
party.accrueInterest(new BigDecimal("52.60"), THROUGH, false);
|
||||
|
||||
assertEquals(new BigDecimal("1052.60"), party.postInterest(new BigDecimal("52.60"), false));
|
||||
assertEquals(new BigDecimal("0.00"), party.accruedInterest(false));
|
||||
}
|
||||
|
||||
/**
|
||||
* The guard against paying the same interest twice: a second posting comes off an accrued
|
||||
* figure that is already empty, so there is nothing left to move.
|
||||
*/
|
||||
@Test
|
||||
void aSecondPostingOfTheSameInterestFindsNothingLeft() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90");
|
||||
party.accrueInterest(new BigDecimal("52.60"), THROUGH, false);
|
||||
party.postInterest(new BigDecimal("52.60"), false);
|
||||
|
||||
assertEquals(new BigDecimal("0.00"), party.accruedInterest(false));
|
||||
}
|
||||
|
||||
/** OD interest has its own figure and its own date - a run of one must not consume the other. */
|
||||
@Test
|
||||
void odInterestIsKeptApartFromOrdinaryInterest() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90");
|
||||
|
||||
party.accrueInterest(new BigDecimal("10.00"), THROUGH, false);
|
||||
party.accrueInterest(new BigDecimal("3.00"), THROUGH.plusDays(1), true);
|
||||
|
||||
assertEquals(new BigDecimal("10.00"), party.accruedInterest(false));
|
||||
assertEquals(new BigDecimal("3.00"), party.accruedInterest(true));
|
||||
assertEquals(THROUGH, party.interestCalculatedThrough(false));
|
||||
assertEquals(THROUGH.plusDays(1), party.interestCalculatedThrough(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void interestAccruesFromWhereTheLastRunStopped() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90");
|
||||
|
||||
assertNull(party.interestCalculatedThrough(false));
|
||||
|
||||
party.accrueInterest(new BigDecimal("5.00"), THROUGH, false);
|
||||
party.accrueInterest(new BigDecimal("5.00"), THROUGH.plusMonths(1), false);
|
||||
|
||||
assertEquals(new BigDecimal("10.00"), party.accruedInterest(false));
|
||||
assertEquals(THROUGH.plusMonths(1), party.interestCalculatedThrough(false));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Closing")
|
||||
class Closing {
|
||||
|
||||
@Test
|
||||
void anAccountIsOpenUntilItIsClosed() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90");
|
||||
|
||||
assertFalse(party.isClosed());
|
||||
|
||||
party.close(LocalDate.of(2026, 12, 1));
|
||||
|
||||
assertTrue(party.isClosed());
|
||||
assertEquals(LocalDate.of(2026, 12, 1), party.getCloseDate());
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-5
@@ -75,11 +75,11 @@ public class AgentsApiResource {
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Create a DDS Agent", description = "Mandatory fields: clientId, agentNameId, agentAddress1, agentCity, "
|
||||
+ "agentPinCode, agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate, homeBranch, "
|
||||
+ "savingBranch, tdsApplicable, commissionCalculation\n" + "\n"
|
||||
+ "Optional fields: agentAddress2, agentTelephone, agentOpeningBalance, tdsPercent, transferBranch, "
|
||||
+ "transferAmountAccountType, transferAccountNo, limit, machineRent, agentSecurityBranch, agentSecurityAccountType, "
|
||||
+ "agentSecurityAccountNo, otherCommission, agentDeposit, festivalAdvance, otherDeduction, limits, guarantors")
|
||||
+ "agentPinCode, agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate\n" + "\n"
|
||||
+ "Optional fields: agentAddress2, agentTelephone, agentOpeningBalance, officeId, guarantors\n" + "\n"
|
||||
+ "officeId is the branch the agent belongs to, and defaults to the client's own office. "
|
||||
+ "agentSavingAccountNo must be an open savings account of that same client - the agent's "
|
||||
+ "commission is paid into it.")
|
||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.createAgent() //
|
||||
@@ -101,4 +101,20 @@ public class AgentsApiResource {
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{agentId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Close a DDS Agent", description = "Ends the relationship with an agent. Refused while they still have "
|
||||
+ "accounts assigned, hold collected cash, or are owed commission - the response names which. Distinct from "
|
||||
+ "deactivating the agent, which stops them collecting and is never refused.")
|
||||
public CommandProcessingResult close(@PathParam("agentId") @Parameter(description = "agentId") final Long agentId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.closeAgent(agentId) //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
}
|
||||
|
||||
+23
-1
@@ -26,11 +26,33 @@ import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface AgentRepository extends JpaRepository<Agent, Long> {
|
||||
|
||||
@Query("select coalesce(max(cast(substring(a.agentCode, 3) as long)), 0) from Agent a where a.agentCode like 'AG%'")
|
||||
/**
|
||||
* Native rather than JPQL: {@code cast(... as long)} reaches PostgreSQL verbatim, which knows no
|
||||
* "long" type - the cast has to name a database type.
|
||||
*/
|
||||
@Query(value = "select coalesce(max(cast(substring(agent_code, 3) as bigint)), 0) from m_dds_agent "
|
||||
+ "where agent_code like 'AG%'", nativeQuery = true)
|
||||
Long retrieveMaxAgentCodeSuffix();
|
||||
|
||||
Optional<Agent> findByAgentCode(String agentCode);
|
||||
|
||||
@Query("select a from Agent a where a.id between :agentFrom and :agentTo")
|
||||
List<Agent> findBetweenIds(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo);
|
||||
|
||||
/**
|
||||
* Agents with commission earned but not yet paid - what a Commission Posting run has to pay out.
|
||||
* Agents owed nothing are left out rather than previewed as zero rows.
|
||||
*/
|
||||
@Query("select a from Agent a where a.id between :agentFrom and :agentTo and a.commission > 0")
|
||||
List<Agent> findWithCommissionOwed(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo);
|
||||
|
||||
/**
|
||||
* The agent already holding this ledger account, if any.
|
||||
*
|
||||
* Asked before an account is handed to a second agent. The database refuses it too, but a
|
||||
* constraint violation names a constraint; this names the agent, which is what the person
|
||||
* choosing needs to hear.
|
||||
*/
|
||||
@Query("select a from Agent a where a.glAccount.id = :glAccountId")
|
||||
Agent findByGlAccountId(@Param("glAccountId") Long glAccountId);
|
||||
}
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "AGENT", action = "CLOSE")
|
||||
public class CloseAgentCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final AgentWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public CloseAgentCommandHandler(final AgentWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.closeAgent(command.entityId(), command);
|
||||
}
|
||||
}
|
||||
+190
-127
@@ -22,26 +22,30 @@ 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.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentCannotGuaranteeItselfException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Validates create/update commands for an Agent - the DDS collection agent. As of 2026-08-24, Agent
|
||||
* does the daily collection work directly (the SubAgent tier was removed), so this deserializer carries
|
||||
* the collection/commission/limit/security-account fields SubAgent used to own, alongside Agent's
|
||||
* original identity/settlement-account fields.
|
||||
* Validates create/update commands for an Agent - the DDS collection agent. The parameter set follows
|
||||
* the legacy Agent Master Entry screen. homeBranch is deliberately absent: it is derived from the
|
||||
* agent's client's office in the write service, not supplied by the caller.
|
||||
*/
|
||||
@Component
|
||||
public final class AgentCommandFromApiJsonDeserializer {
|
||||
@@ -58,28 +62,55 @@ public final class AgentCommandFromApiJsonDeserializer {
|
||||
public static final String AGENT_OPENING_DATE = "agentOpeningDate";
|
||||
public static final String AGENT_BIRTH_DATE = "agentBirthDate";
|
||||
public static final String AGENT_OPENING_BALANCE = "agentOpeningBalance";
|
||||
public static final String HOME_BRANCH = "homeBranch";
|
||||
public static final String SAVING_BRANCH = "savingBranch";
|
||||
public static final String TDS_APPLICABLE = "tdsApplicable";
|
||||
public static final String TDS_PERCENT = "tdsPercent";
|
||||
public static final String TRANSFER_BRANCH = "transferBranch";
|
||||
public static final String TRANSFER_AMOUNT_ACCOUNT_TYPE = "transferAmountAccountType";
|
||||
public static final String TRANSFER_ACCOUNT_NO = "transferAccountNo";
|
||||
public static final String COMMISSION_CALCULATION = "commissionCalculation";
|
||||
public static final String LIMIT = "limit";
|
||||
public static final String MACHINE_RENT = "machineRent";
|
||||
public static final String AGENT_SECURITY_BRANCH = "agentSecurityBranch";
|
||||
public static final String AGENT_SECURITY_ACCOUNT_TYPE = "agentSecurityAccountType";
|
||||
public static final String AGENT_SECURITY_ACCOUNT_NO = "agentSecurityAccountNo";
|
||||
public static final String OTHER_COMMISSION = "otherCommission";
|
||||
public static final String AGENT_DEPOSIT = "agentDeposit";
|
||||
public static final String FESTIVAL_ADVANCE = "festivalAdvance";
|
||||
public static final String OTHER_DEDUCTION = "otherDeduction";
|
||||
public static final String LIMITS = "limits";
|
||||
|
||||
/**
|
||||
* An agent handles the branch's cash and signs for it, so they have to be an adult. Kept as a
|
||||
* named constant rather than written into the check, because it is a policy number the bank may
|
||||
* one day want to read off, not an arithmetic detail.
|
||||
*/
|
||||
private static final int MINIMUM_AGENT_AGE_YEARS = 18;
|
||||
|
||||
/**
|
||||
* Which branch the agent works out of, as an office id. Optional: it defaults to the client's
|
||||
* own office, and is only sent when the agent collects out of a different branch from the one
|
||||
* their client record sits in. The DDS batch runs filter by it.
|
||||
*
|
||||
* The branch name is deliberately not accepted - it is derived from the office the id names, so
|
||||
* a caller cannot file an agent under a branch that does not exist, or under a name that
|
||||
* disagrees with the office actually stored.
|
||||
*/
|
||||
public static final String OFFICE_ID = "officeId";
|
||||
|
||||
/**
|
||||
* The ledger account this agent's collections pass through. Required, and one agent to an
|
||||
* account: what is left standing on it is the cash this agent is holding and has not brought in,
|
||||
* which is a question nobody could answer while every agent shared one account.
|
||||
*
|
||||
* The agents created before this existed carry none, and go on posting to the shared account
|
||||
* until somebody opens them and chooses - the rule is enforced on what is written from here on,
|
||||
* not applied backwards over records nobody has looked at.
|
||||
*/
|
||||
public static final String GL_ACCOUNT_ID = "glAccountId";
|
||||
|
||||
public static final String ACTIVE = "active";
|
||||
public static final String GUARANTORS = "guarantors";
|
||||
public static final String DATE_FORMAT = "dateFormat";
|
||||
public static final String LOCALE = "locale";
|
||||
|
||||
/**
|
||||
* Commission settings and the standing deductions the commission run applies. Update-only: an
|
||||
* agent is created earning full commission with nothing deducted, and these are set afterwards
|
||||
* when the bank decides otherwise. The running totals the batch maintains - the commission owed
|
||||
* and the TDS withheld - are not parameters at all; they are outcomes of posted runs.
|
||||
*/
|
||||
public static final String COMMISSION_CALCULATION = "commissionCalculation";
|
||||
public static final String TDS_APPLICABLE = "tdsApplicable";
|
||||
public static final String TDS_PERCENT = "tdsPercent";
|
||||
public static final String DEDUCT_COMMISSION = "deductCommission";
|
||||
public static final String AGENT_DEPOSIT = "agentDeposit";
|
||||
public static final String FESTIVAL_ADVANCE = "festivalAdvance";
|
||||
public static final String OTHER_DEDUCTION = "otherDeduction";
|
||||
|
||||
private static final String GUARANTOR_CLIENT_ID = "clientId";
|
||||
private static final String GUARANTOR_NAME = "guarantorName";
|
||||
private static final String GUARANTOR_ADDRESS1 = "address1";
|
||||
@@ -90,16 +121,29 @@ public final class AgentCommandFromApiJsonDeserializer {
|
||||
|
||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList(CLIENT_ID, AGENT_NAME_ID, AGENT_ADDRESS1,
|
||||
AGENT_ADDRESS2, AGENT_CITY, AGENT_PINCODE, AGENT_TELEPHONE, AGENT_SAVING_ACCOUNT_TYPE, AGENT_SAVING_ACCOUNT_NO,
|
||||
AGENT_OPENING_DATE, AGENT_BIRTH_DATE, AGENT_OPENING_BALANCE, HOME_BRANCH, SAVING_BRANCH, TDS_APPLICABLE, TDS_PERCENT,
|
||||
TRANSFER_BRANCH, TRANSFER_AMOUNT_ACCOUNT_TYPE, TRANSFER_ACCOUNT_NO, COMMISSION_CALCULATION, LIMIT, MACHINE_RENT,
|
||||
AGENT_SECURITY_BRANCH, AGENT_SECURITY_ACCOUNT_TYPE, AGENT_SECURITY_ACCOUNT_NO, OTHER_COMMISSION, AGENT_DEPOSIT,
|
||||
FESTIVAL_ADVANCE, OTHER_DEDUCTION, LIMITS, GUARANTORS, DATE_FORMAT, LOCALE));
|
||||
AGENT_OPENING_DATE, AGENT_BIRTH_DATE, AGENT_OPENING_BALANCE, OFFICE_ID, GL_ACCOUNT_ID, ACTIVE, GUARANTORS, DATE_FORMAT,
|
||||
LOCALE));
|
||||
|
||||
private static final Set<String> SUPPORTED_LIMIT_PARAMETERS = new HashSet<>(Arrays.asList("fromDate", "toDate", "limit"));
|
||||
private static final Set<String> SUPPORTED_UPDATE_PARAMETERS = new HashSet<>(SUPPORTED_PARAMETERS);
|
||||
static {
|
||||
SUPPORTED_UPDATE_PARAMETERS.addAll(Arrays.asList(COMMISSION_CALCULATION, TDS_APPLICABLE, TDS_PERCENT, DEDUCT_COMMISSION,
|
||||
AGENT_DEPOSIT, FESTIVAL_ADVANCE, OTHER_DEDUCTION));
|
||||
}
|
||||
|
||||
private static final Set<String> SUPPORTED_GUARANTOR_PARAMETERS = new HashSet<>(Arrays.asList(GUARANTOR_CLIENT_ID, GUARANTOR_NAME,
|
||||
GUARANTOR_ADDRESS1, GUARANTOR_ADDRESS2, GUARANTOR_CITY, GUARANTOR_PINCODE, GUARANTOR_TELEPHONE));
|
||||
|
||||
/*
|
||||
* What a phone number and a pincode have to look like.
|
||||
*
|
||||
* Length alone was all these carried, so either could be typed as letters and nothing would say
|
||||
* so until somebody tried to ring the agent or post to them. Kept in step with the Party screen,
|
||||
* which asks for the same two things of the customer.
|
||||
*/
|
||||
private static final Pattern TELEPHONE_PATTERN = Pattern.compile("^[0-9+()\\-\\s]{6,20}$");
|
||||
|
||||
private static final Pattern PINCODE_PATTERN = Pattern.compile("^[1-9][0-9]{5}$");
|
||||
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
|
||||
@Autowired
|
||||
@@ -141,10 +185,14 @@ public final class AgentCommandFromApiJsonDeserializer {
|
||||
|
||||
final String agentPinCode = this.fromApiJsonHelper.extractStringNamed(AGENT_PINCODE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_PINCODE).value(agentPinCode).notBlank().notExceedingLengthOf(20);
|
||||
mustLookLike(baseDataValidator, AGENT_PINCODE, agentPinCode, PINCODE_PATTERN,
|
||||
"Pincode must be 6 digits and cannot start with 0");
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_TELEPHONE, element)) {
|
||||
final String agentTelephone = this.fromApiJsonHelper.extractStringNamed(AGENT_TELEPHONE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_TELEPHONE).value(agentTelephone).ignoreIfNull().notExceedingLengthOf(20);
|
||||
mustLookLike(baseDataValidator, AGENT_TELEPHONE, agentTelephone, TELEPHONE_PATTERN,
|
||||
"Telephone number may contain only digits, spaces and + - ( )");
|
||||
}
|
||||
|
||||
final String agentSavingAccountType = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_TYPE, element);
|
||||
@@ -154,33 +202,34 @@ public final class AgentCommandFromApiJsonDeserializer {
|
||||
final String agentSavingAccountNo = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_NO, element);
|
||||
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_NO).value(agentSavingAccountNo).notBlank().notExceedingLengthOf(50);
|
||||
|
||||
// Optional: the write service falls back to the client's own office. Only checked for shape
|
||||
// here - that the office exists is the write service's business.
|
||||
if (this.fromApiJsonHelper.parameterExists(OFFICE_ID, element)) {
|
||||
final Long officeId = this.fromApiJsonHelper.extractLongNamed(OFFICE_ID, element);
|
||||
baseDataValidator.reset().parameter(OFFICE_ID).value(officeId).ignoreIfNull().integerGreaterThanZero();
|
||||
}
|
||||
|
||||
// Required, because an agent without one of their own falls back to the account every other
|
||||
// agent shares - and a balance sitting on that account says money is outstanding without
|
||||
// saying whose, which is the whole thing this field exists to stop.
|
||||
//
|
||||
// Shape only. Whether the account exists, may carry entries, and is not already another
|
||||
// agent's is the write service's business - it has the ledger and the other agents to hand.
|
||||
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(GL_ACCOUNT_ID, element);
|
||||
baseDataValidator.reset().parameter(GL_ACCOUNT_ID).value(glAccountId).notNull().integerGreaterThanZero();
|
||||
|
||||
final var agentOpeningDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_OPENING_DATE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_OPENING_DATE).value(agentOpeningDate).notNull();
|
||||
|
||||
final var agentBirthDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_BIRTH_DATE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate).notNull();
|
||||
validateAgentBirthDate(agentBirthDate, baseDataValidator);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_BALANCE, element)) {
|
||||
final var agentOpeningBalance = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AGENT_OPENING_BALANCE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_OPENING_BALANCE).value(agentOpeningBalance).ignoreIfNull().zeroOrPositiveAmount();
|
||||
}
|
||||
|
||||
final String homeBranch = this.fromApiJsonHelper.extractStringNamed(HOME_BRANCH, element);
|
||||
baseDataValidator.reset().parameter(HOME_BRANCH).value(homeBranch).notBlank().notExceedingLengthOf(100);
|
||||
|
||||
final String savingBranch = this.fromApiJsonHelper.extractStringNamed(SAVING_BRANCH, element);
|
||||
baseDataValidator.reset().parameter(SAVING_BRANCH).value(savingBranch).notBlank().notExceedingLengthOf(100);
|
||||
|
||||
baseDataValidator.reset().parameter(TDS_APPLICABLE).trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(TDS_APPLICABLE,
|
||||
element));
|
||||
baseDataValidator.reset().parameter(COMMISSION_CALCULATION)
|
||||
.trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(COMMISSION_CALCULATION, element));
|
||||
|
||||
validateOptionalStrings(element, baseDataValidator);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(LIMITS, element)) {
|
||||
validateLimits(element.getAsJsonObject(), dataValidationErrors);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(GUARANTORS, element)) {
|
||||
validateGuarantors(element.getAsJsonObject(), dataValidationErrors);
|
||||
}
|
||||
@@ -196,7 +245,7 @@ public final class AgentCommandFromApiJsonDeserializer {
|
||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
|
||||
|
||||
}.getType();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_UPDATE_PARAMETERS);
|
||||
|
||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent");
|
||||
@@ -231,11 +280,15 @@ public final class AgentCommandFromApiJsonDeserializer {
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_PINCODE, element)) {
|
||||
final String agentPinCode = this.fromApiJsonHelper.extractStringNamed(AGENT_PINCODE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_PINCODE).value(agentPinCode).notBlank().notExceedingLengthOf(20);
|
||||
mustLookLike(baseDataValidator, AGENT_PINCODE, agentPinCode, PINCODE_PATTERN,
|
||||
"Pincode must be 6 digits and cannot start with 0");
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_TELEPHONE, element)) {
|
||||
final String agentTelephone = this.fromApiJsonHelper.extractStringNamed(AGENT_TELEPHONE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_TELEPHONE).value(agentTelephone).ignoreIfNull().notExceedingLengthOf(20);
|
||||
mustLookLike(baseDataValidator, AGENT_TELEPHONE, agentTelephone, TELEPHONE_PATTERN,
|
||||
"Telephone number may contain only digits, spaces and + - ( )");
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_SAVING_ACCOUNT_TYPE, element)) {
|
||||
@@ -257,6 +310,7 @@ public final class AgentCommandFromApiJsonDeserializer {
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_BIRTH_DATE, element)) {
|
||||
final var agentBirthDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_BIRTH_DATE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate).notNull();
|
||||
validateAgentBirthDate(agentBirthDate, baseDataValidator);
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_BALANCE, element)) {
|
||||
@@ -264,31 +318,43 @@ public final class AgentCommandFromApiJsonDeserializer {
|
||||
baseDataValidator.reset().parameter(AGENT_OPENING_BALANCE).value(agentOpeningBalance).ignoreIfNull().zeroOrPositiveAmount();
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(HOME_BRANCH, element)) {
|
||||
final String homeBranch = this.fromApiJsonHelper.extractStringNamed(HOME_BRANCH, element);
|
||||
baseDataValidator.reset().parameter(HOME_BRANCH).value(homeBranch).notBlank().notExceedingLengthOf(100);
|
||||
// Sent means chosen: an update may leave the field out and keep what the agent has, but it
|
||||
// may not send it empty. Clearing it would put the agent back on the shared account, which
|
||||
// is the state this field was added to get out of.
|
||||
if (this.fromApiJsonHelper.parameterExists(GL_ACCOUNT_ID, element)) {
|
||||
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(GL_ACCOUNT_ID, element);
|
||||
baseDataValidator.reset().parameter(GL_ACCOUNT_ID).value(glAccountId).notNull().integerGreaterThanZero();
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(SAVING_BRANCH, element)) {
|
||||
final String savingBranch = this.fromApiJsonHelper.extractStringNamed(SAVING_BRANCH, element);
|
||||
baseDataValidator.reset().parameter(SAVING_BRANCH).value(savingBranch).notBlank().notExceedingLengthOf(100);
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(TDS_APPLICABLE, element)) {
|
||||
baseDataValidator.reset().parameter(TDS_APPLICABLE)
|
||||
.trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(TDS_APPLICABLE, element));
|
||||
// Only ever sent on its own, by the list's activate/deactivate action - an agent is always
|
||||
// created active, so this is an update-time parameter.
|
||||
if (this.fromApiJsonHelper.parameterExists(ACTIVE, element)) {
|
||||
final Boolean active = this.fromApiJsonHelper.extractBooleanNamed(ACTIVE, element);
|
||||
baseDataValidator.reset().parameter(ACTIVE).value(active).notNull();
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(COMMISSION_CALCULATION, element)) {
|
||||
baseDataValidator.reset().parameter(COMMISSION_CALCULATION)
|
||||
.trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(COMMISSION_CALCULATION, element));
|
||||
final Boolean commissionCalculation = this.fromApiJsonHelper.extractBooleanNamed(COMMISSION_CALCULATION, element);
|
||||
baseDataValidator.reset().parameter(COMMISSION_CALCULATION).value(commissionCalculation).notNull();
|
||||
}
|
||||
|
||||
validateOptionalStrings(element, baseDataValidator);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(LIMITS, element)) {
|
||||
validateLimits(element.getAsJsonObject(), dataValidationErrors);
|
||||
if (this.fromApiJsonHelper.parameterExists(TDS_APPLICABLE, element)) {
|
||||
final Boolean tdsApplicable = this.fromApiJsonHelper.extractBooleanNamed(TDS_APPLICABLE, element);
|
||||
baseDataValidator.reset().parameter(TDS_APPLICABLE).value(tdsApplicable).notNull();
|
||||
}
|
||||
|
||||
// A tax rate above 100% would take more than the commission it is levied on.
|
||||
if (this.fromApiJsonHelper.parameterExists(TDS_PERCENT, element)) {
|
||||
final var tdsPercent = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(TDS_PERCENT, element);
|
||||
baseDataValidator.reset().parameter(TDS_PERCENT).value(tdsPercent).ignoreIfNull().zeroOrPositiveAmount()
|
||||
.notGreaterThanMax(BigDecimal.valueOf(100));
|
||||
}
|
||||
|
||||
validateDeduction(baseDataValidator, element, DEDUCT_COMMISSION);
|
||||
validateDeduction(baseDataValidator, element, AGENT_DEPOSIT);
|
||||
validateDeduction(baseDataValidator, element, FESTIVAL_ADVANCE);
|
||||
validateDeduction(baseDataValidator, element, OTHER_DEDUCTION);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(GUARANTORS, element)) {
|
||||
validateGuarantors(element.getAsJsonObject(), dataValidationErrors);
|
||||
}
|
||||
@@ -296,87 +362,67 @@ public final class AgentCommandFromApiJsonDeserializer {
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
}
|
||||
|
||||
private void validateOptionalStrings(final JsonElement element, final DataValidatorBuilder v) {
|
||||
if (this.fromApiJsonHelper.parameterExists(TDS_PERCENT, element)) {
|
||||
v.reset().parameter(TDS_PERCENT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(TDS_PERCENT, element))
|
||||
.ignoreIfNull().zeroOrPositiveAmount();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(TRANSFER_BRANCH, element)) {
|
||||
v.reset().parameter(TRANSFER_BRANCH).value(this.fromApiJsonHelper.extractStringNamed(TRANSFER_BRANCH, element))
|
||||
.ignoreIfNull().notExceedingLengthOf(100);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(TRANSFER_AMOUNT_ACCOUNT_TYPE, element)) {
|
||||
v.reset().parameter(TRANSFER_AMOUNT_ACCOUNT_TYPE)
|
||||
.value(this.fromApiJsonHelper.extractStringNamed(TRANSFER_AMOUNT_ACCOUNT_TYPE, element)).ignoreIfNull()
|
||||
.notExceedingLengthOf(100);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(TRANSFER_ACCOUNT_NO, element)) {
|
||||
v.reset().parameter(TRANSFER_ACCOUNT_NO).value(this.fromApiJsonHelper.extractStringNamed(TRANSFER_ACCOUNT_NO, element))
|
||||
.ignoreIfNull().notExceedingLengthOf(50);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(LIMIT, element)) {
|
||||
v.reset().parameter(LIMIT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(LIMIT, element)).ignoreIfNull()
|
||||
.zeroOrPositiveAmount();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(MACHINE_RENT, element)) {
|
||||
v.reset().parameter(MACHINE_RENT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(MACHINE_RENT, element))
|
||||
.ignoreIfNull().zeroOrPositiveAmount();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_SECURITY_BRANCH, element)) {
|
||||
v.reset().parameter(AGENT_SECURITY_BRANCH).value(this.fromApiJsonHelper.extractStringNamed(AGENT_SECURITY_BRANCH, element))
|
||||
.ignoreIfNull().notExceedingLengthOf(100);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_SECURITY_ACCOUNT_TYPE, element)) {
|
||||
v.reset().parameter(AGENT_SECURITY_ACCOUNT_TYPE)
|
||||
.value(this.fromApiJsonHelper.extractStringNamed(AGENT_SECURITY_ACCOUNT_TYPE, element)).ignoreIfNull()
|
||||
.notExceedingLengthOf(100);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_SECURITY_ACCOUNT_NO, element)) {
|
||||
v.reset().parameter(AGENT_SECURITY_ACCOUNT_NO)
|
||||
.value(this.fromApiJsonHelper.extractStringNamed(AGENT_SECURITY_ACCOUNT_NO, element)).ignoreIfNull()
|
||||
.notExceedingLengthOf(50);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(OTHER_COMMISSION, element)) {
|
||||
v.reset().parameter(OTHER_COMMISSION).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(OTHER_COMMISSION, element))
|
||||
.ignoreIfNull().zeroOrPositiveAmount();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_DEPOSIT, element)) {
|
||||
v.reset().parameter(AGENT_DEPOSIT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AGENT_DEPOSIT, element))
|
||||
.ignoreIfNull().zeroOrPositiveAmount();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(FESTIVAL_ADVANCE, element)) {
|
||||
v.reset().parameter(FESTIVAL_ADVANCE).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(FESTIVAL_ADVANCE, element))
|
||||
.ignoreIfNull().zeroOrPositiveAmount();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(OTHER_DEDUCTION, element)) {
|
||||
v.reset().parameter(OTHER_DEDUCTION).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(OTHER_DEDUCTION, element))
|
||||
.ignoreIfNull().zeroOrPositiveAmount();
|
||||
/**
|
||||
* A deduction is an amount withheld, so it is entered as a positive figure - a negative one would
|
||||
* quietly pay the agent more rather than less.
|
||||
*/
|
||||
private void validateDeduction(final DataValidatorBuilder baseDataValidator, final JsonElement element, final String parameterName) {
|
||||
if (this.fromApiJsonHelper.parameterExists(parameterName, element)) {
|
||||
final var value = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(parameterName, element);
|
||||
baseDataValidator.reset().parameter(parameterName).value(value).ignoreIfNull().zeroOrPositiveAmount();
|
||||
}
|
||||
}
|
||||
|
||||
private void validateLimits(final JsonObject topLevelJson, final List<ApiParameterError> dataValidationErrors) {
|
||||
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("agent.limit");
|
||||
final var limitsArray = topLevelJson.getAsJsonArray(LIMITS);
|
||||
for (int i = 0; i < limitsArray.size(); i++) {
|
||||
final JsonObject limitJson = limitsArray.get(i).getAsJsonObject();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(limitJson, SUPPORTED_LIMIT_PARAMETERS);
|
||||
/**
|
||||
* A date of birth that is today or later describes nobody, and until now nothing stopped one
|
||||
* being saved - the field was only checked for being present. The slip is easy to make, because
|
||||
* the date picker opens on today and a stray Enter takes it.
|
||||
*
|
||||
* The age is measured against today rather than against the opening date: an agent opened years
|
||||
* ago has only grown older since, so today is the reading that cannot wrongly reject a record
|
||||
* that was legitimate when it was made.
|
||||
*
|
||||
* Only the shape of the date is judged here. Whether the person should be an agent at all is the
|
||||
* branch's decision, not the validator's.
|
||||
*/
|
||||
private void validateAgentBirthDate(final LocalDate agentBirthDate, final DataValidatorBuilder baseDataValidator) {
|
||||
if (agentBirthDate == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
v.reset().parameterAtIndexArray("fromDate", i).value(extractString(limitJson, "fromDate")).notBlank();
|
||||
v.reset().parameterAtIndexArray("toDate", i).value(extractString(limitJson, "toDate")).notBlank();
|
||||
v.reset().parameterAtIndexArray("limit", i).value(extractBigDecimal(limitJson, "limit")).notNull().zeroOrPositiveAmount();
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
|
||||
if (!agentBirthDate.isBefore(today)) {
|
||||
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate)
|
||||
.failWithCode("cannot.be.today.or.a.future.date");
|
||||
return;
|
||||
}
|
||||
|
||||
if (agentBirthDate.isAfter(today.minusYears(MINIMUM_AGENT_AGE_YEARS))) {
|
||||
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate)
|
||||
.failWithCode("agent.must.be.at.least." + MINIMUM_AGENT_AGE_YEARS + ".years.old");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateGuarantors(final JsonObject topLevelJson, final List<ApiParameterError> dataValidationErrors) {
|
||||
final DataValidatorBuilder guarantorValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent.guarantor");
|
||||
|
||||
final Long agentClientId = extractLong(topLevelJson, CLIENT_ID);
|
||||
|
||||
final var guarantorsArray = topLevelJson.getAsJsonArray(GUARANTORS);
|
||||
for (int i = 0; i < guarantorsArray.size(); i++) {
|
||||
final JsonObject guarantorJson = guarantorsArray.get(i).getAsJsonObject();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(guarantorJson, SUPPORTED_GUARANTOR_PARAMETERS);
|
||||
|
||||
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_CLIENT_ID, i)
|
||||
.value(extractLong(guarantorJson, GUARANTOR_CLIENT_ID)).notNull().integerGreaterThanZero();
|
||||
final Long guarantorClientId = extractLong(guarantorJson, GUARANTOR_CLIENT_ID);
|
||||
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_CLIENT_ID, i).value(guarantorClientId).notNull()
|
||||
.integerGreaterThanZero();
|
||||
|
||||
// An agent cannot stand guarantor for itself - a guarantee only counts coming from
|
||||
// someone else.
|
||||
if (agentClientId != null && agentClientId.equals(guarantorClientId)) {
|
||||
throw new AgentCannotGuaranteeItselfException(agentClientId);
|
||||
}
|
||||
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_NAME, i).value(extractString(guarantorJson, GUARANTOR_NAME))
|
||||
.notBlank().notExceedingLengthOf(100);
|
||||
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_ADDRESS1, i).value(extractString(guarantorJson, GUARANTOR_ADDRESS1))
|
||||
@@ -406,4 +452,21 @@ public final class AgentCommandFromApiJsonDeserializer {
|
||||
dataValidationErrors);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a value against the shape its kind has to take, and says what that shape is.
|
||||
*
|
||||
* The message names the format rather than only rejecting the entry: "must be 6 digits" tells
|
||||
* the clerk what to type next, where "invalid" leaves them guessing.
|
||||
*/
|
||||
private void mustLookLike(final DataValidatorBuilder v, final String paramName, final String value, final Pattern shape,
|
||||
final String describedAs) {
|
||||
if (org.apache.commons.lang3.StringUtils.isBlank(value)) {
|
||||
return;
|
||||
}
|
||||
// The message is given to the validator rather than left in the args, so what the clerk
|
||||
// reads is what to type next rather than that something was wrong.
|
||||
v.reset().parameter(paramName).value(value).matchesRegularExpression(shape.pattern(), describedAs);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+62
-63
@@ -20,6 +20,8 @@ package org.apache.fineract.organisation.agent.service;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -29,7 +31,6 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
|
||||
import org.apache.fineract.organisation.agent.data.AgentData;
|
||||
import org.apache.fineract.organisation.agent.data.AgentGuarantorData;
|
||||
import org.apache.fineract.organisation.agent.data.AgentLimitData;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -41,7 +42,6 @@ public class AgentReadPlatformServiceImpl implements AgentReadPlatformService {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
private static final AgentMapper AGENT_MAPPER = new AgentMapper();
|
||||
private static final AgentLimitMapper LIMIT_MAPPER = new AgentLimitMapper();
|
||||
private static final AgentGuarantorMapper GUARANTOR_MAPPER = new AgentGuarantorMapper();
|
||||
|
||||
private static final class AgentMapper implements RowMapper<AgentData> {
|
||||
@@ -53,50 +53,68 @@ public class AgentReadPlatformServiceImpl implements AgentReadPlatformService {
|
||||
+ " a.agent_saving_account_type as agentSavingAccountType, a.agent_saving_account_no as agentSavingAccountNo, "
|
||||
+ " a.agent_opening_date as agentOpeningDate, a.agent_birth_date as agentBirthDate, "
|
||||
+ " a.agent_current_balance as agentCurrentBalance, a.agent_opening_balance as agentOpeningBalance, "
|
||||
+ " a.home_branch as homeBranch, a.saving_branch as savingBranch, a.tds_applicable as tdsApplicable, "
|
||||
+ " a.tds_percent as tdsPercent, a.current_tds as currentTds, a.tds_amount as tdsAmount, "
|
||||
+ " a.transfer_branch as transferBranch, a.transfer_amount_account_type as transferAmountAccountType, "
|
||||
+ " a.transfer_account_no as transferAccountNo, a.collector_commission_calculation as commissionCalculation, "
|
||||
+ " a.collector_limit_amount as limitAmount, a.machine_rent as machineRent, "
|
||||
+ " a.agent_security_branch as agentSecurityBranch, a.agent_security_account_type as agentSecurityAccountType, "
|
||||
+ " a.agent_security_account_no as agentSecurityAccountNo, a.commission as commission, "
|
||||
+ " a.other_commission as otherCommission, a.agent_deposit as agentDeposit, "
|
||||
+ " a.festival_advance as festivalAdvance, a.other_deduction as otherDeduction "
|
||||
+ " from m_dds_agent a join m_client c on c.id = a.client_id";
|
||||
// The branch name is read off the office itself, so renaming a branch shows
|
||||
// through here rather than leaving every agent under the old name.
|
||||
+ " a.office_id as officeId, coalesce(o.name, a.home_branch) as homeBranch, "
|
||||
// The ledger account this agent's collections pass through, with its code and name so
|
||||
// the screen can show what was chosen without a second call.
|
||||
+ " a.gl_account_id as glAccountId, gl.gl_code as glAccountCode, gl.name as glAccountName, "
|
||||
+ " a.commission as commission, a.tds_amount as tdsAmount, "
|
||||
+ " a.commission_calculation as commissionCalculation, a.tds_applicable as tdsApplicable, "
|
||||
+ " a.tds_percent as tdsPercent, a.deduct_commission as deductCommission, a.agent_deposit as agentDeposit, "
|
||||
+ " a.festival_advance as festivalAdvance, a.other_deduction as otherDeduction, "
|
||||
+ " a.is_active as active, a.close_date as closeDate, "
|
||||
+ " a.created_date as createdDate, a.lastmodified_date as lastModifiedDate "
|
||||
+ " from m_dds_agent a join m_client c on c.id = a.client_id"
|
||||
+ " left join m_office o on o.id = a.office_id"
|
||||
+ " left join acc_gl_account gl on gl.id = a.gl_account_id";
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgentData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||
return AgentData.instance(rs.getLong("id"), rs.getLong("clientId"), rs.getString("clientName"), rs.getString("agentCode"),
|
||||
rs.getString("agentNameId"), rs.getString("agentAddress1"), rs.getString("agentAddress2"),
|
||||
rs.getString("agentCity"), rs.getString("agentPinCode"), rs.getString("agentTelephone"),
|
||||
rs.getString("agentSavingAccountType"), rs.getString("agentSavingAccountNo"),
|
||||
JdbcSupport.getLocalDate(rs, "agentOpeningDate"), JdbcSupport.getLocalDate(rs, "agentBirthDate"),
|
||||
rs.getBigDecimal("agentCurrentBalance"), rs.getBigDecimal("agentOpeningBalance"), rs.getString("homeBranch"),
|
||||
rs.getString("savingBranch"), rs.getBoolean("tdsApplicable"), rs.getBigDecimal("tdsPercent"),
|
||||
rs.getBigDecimal("currentTds"), rs.getBigDecimal("tdsAmount"), rs.getString("transferBranch"),
|
||||
rs.getString("transferAmountAccountType"), rs.getString("transferAccountNo"),
|
||||
rs.getBoolean("commissionCalculation"), rs.getBigDecimal("limitAmount"), rs.getBigDecimal("machineRent"),
|
||||
rs.getString("agentSecurityBranch"), rs.getString("agentSecurityAccountType"),
|
||||
rs.getString("agentSecurityAccountNo"), rs.getBigDecimal("commission"), rs.getBigDecimal("otherCommission"),
|
||||
rs.getBigDecimal("agentDeposit"), rs.getBigDecimal("festivalAdvance"), rs.getBigDecimal("otherDeduction"),
|
||||
new ArrayList<>(), new ArrayList<>());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AgentLimitMapper implements RowMapper<Map.Entry<Long, AgentLimitData>> {
|
||||
|
||||
public String schema() {
|
||||
return " l.agent_id as agentId, l.id as id, l.from_date as fromDate, l.to_date as toDate, "
|
||||
+ " l.limit_amount as limitAmount from m_dds_agent_limit l";
|
||||
return AgentData.builder() //
|
||||
.id(rs.getLong("id")) //
|
||||
.clientId(rs.getLong("clientId")) //
|
||||
.clientName(rs.getString("clientName")) //
|
||||
.agentCode(rs.getString("agentCode")) //
|
||||
.agentNameId(rs.getString("agentNameId")) //
|
||||
.agentAddress1(rs.getString("agentAddress1")) //
|
||||
.agentAddress2(rs.getString("agentAddress2")) //
|
||||
.agentCity(rs.getString("agentCity")) //
|
||||
.agentPinCode(rs.getString("agentPinCode")) //
|
||||
.agentTelephone(rs.getString("agentTelephone")) //
|
||||
.agentSavingAccountType(rs.getString("agentSavingAccountType")) //
|
||||
.agentSavingAccountNo(rs.getString("agentSavingAccountNo")) //
|
||||
.agentOpeningDate(JdbcSupport.getLocalDate(rs, "agentOpeningDate")) //
|
||||
.agentBirthDate(JdbcSupport.getLocalDate(rs, "agentBirthDate")) //
|
||||
.agentCurrentBalance(rs.getBigDecimal("agentCurrentBalance")) //
|
||||
.agentOpeningBalance(rs.getBigDecimal("agentOpeningBalance")) //
|
||||
.officeId(JdbcSupport.getLong(rs, "officeId")) //
|
||||
.glAccountId(JdbcSupport.getLong(rs, "glAccountId")) //
|
||||
.glAccountCode(rs.getString("glAccountCode")) //
|
||||
.glAccountName(rs.getString("glAccountName")) //
|
||||
.homeBranch(rs.getString("homeBranch")) //
|
||||
.commission(rs.getBigDecimal("commission")) //
|
||||
.tdsAmount(rs.getBigDecimal("tdsAmount")) //
|
||||
.commissionCalculation(rs.getBoolean("commissionCalculation")) //
|
||||
.tdsApplicable(rs.getBoolean("tdsApplicable")) //
|
||||
.tdsPercent(rs.getBigDecimal("tdsPercent")) //
|
||||
.deductCommission(rs.getBigDecimal("deductCommission")) //
|
||||
.agentDeposit(rs.getBigDecimal("agentDeposit")) //
|
||||
.festivalAdvance(rs.getBigDecimal("festivalAdvance")) //
|
||||
.otherDeduction(rs.getBigDecimal("otherDeduction")) //
|
||||
.active(rs.getBoolean("active")) //
|
||||
.closeDate(JdbcSupport.getLocalDate(rs, "closeDate")) //
|
||||
.createdDate(localDateTime(rs, "createdDate")) //
|
||||
.lastModifiedDate(localDateTime(rs, "lastModifiedDate")) //
|
||||
.guarantors(new ArrayList<>()) //
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map.Entry<Long, AgentLimitData> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||
final Long agentId = rs.getLong("agentId");
|
||||
final AgentLimitData data = AgentLimitData.instance(rs.getLong("id"), JdbcSupport.getLocalDate(rs, "fromDate"),
|
||||
JdbcSupport.getLocalDate(rs, "toDate"), rs.getBigDecimal("limitAmount"));
|
||||
return Map.entry(agentId, data);
|
||||
/** JdbcSupport has no LocalDateTime helper, and these audit columns are nullable. */
|
||||
private LocalDateTime localDateTime(final ResultSet rs, final String columnName) throws SQLException {
|
||||
final Timestamp timestamp = rs.getTimestamp(columnName);
|
||||
return timestamp == null ? null : timestamp.toLocalDateTime();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +140,7 @@ public class AgentReadPlatformServiceImpl implements AgentReadPlatformService {
|
||||
public Collection<AgentData> retrieveAll() {
|
||||
final String sql = "select " + AGENT_MAPPER.schema() + " order by a.id";
|
||||
final List<AgentData> agents = this.jdbcTemplate.query(sql, AGENT_MAPPER); // NOSONAR
|
||||
return attachChildren(agents);
|
||||
return attachGuarantors(agents);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -130,24 +148,17 @@ public class AgentReadPlatformServiceImpl implements AgentReadPlatformService {
|
||||
try {
|
||||
final String sql = "select " + AGENT_MAPPER.schema() + " where a.id = ?";
|
||||
final AgentData agent = this.jdbcTemplate.queryForObject(sql, AGENT_MAPPER, agentId); // NOSONAR
|
||||
return attachChildren(List.of(agent)).iterator().next();
|
||||
return attachGuarantors(List.of(agent)).iterator().next();
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
throw new AgentNotFoundException(agentId, e);
|
||||
}
|
||||
}
|
||||
|
||||
private Collection<AgentData> attachChildren(final List<AgentData> agents) {
|
||||
private Collection<AgentData> attachGuarantors(final List<AgentData> agents) {
|
||||
if (agents.isEmpty()) {
|
||||
return agents;
|
||||
}
|
||||
|
||||
final String limitSql = "select " + LIMIT_MAPPER.schema() + " order by l.id";
|
||||
final List<Map.Entry<Long, AgentLimitData>> limitRows = this.jdbcTemplate.query(limitSql, LIMIT_MAPPER); // NOSONAR
|
||||
final Map<Long, List<AgentLimitData>> limitsByAgentId = new LinkedHashMap<>();
|
||||
for (final Map.Entry<Long, AgentLimitData> row : limitRows) {
|
||||
limitsByAgentId.computeIfAbsent(row.getKey(), k -> new ArrayList<>()).add(row.getValue());
|
||||
}
|
||||
|
||||
final String guarantorSql = "select " + GUARANTOR_MAPPER.schema() + " order by g.id";
|
||||
final List<Map.Entry<Long, AgentGuarantorData>> guarantorRows = this.jdbcTemplate.query(guarantorSql, GUARANTOR_MAPPER); // NOSONAR
|
||||
final Map<Long, List<AgentGuarantorData>> guarantorsByAgentId = new LinkedHashMap<>();
|
||||
@@ -157,19 +168,7 @@ public class AgentReadPlatformServiceImpl implements AgentReadPlatformService {
|
||||
|
||||
final List<AgentData> result = new ArrayList<>(agents.size());
|
||||
for (final AgentData agent : agents) {
|
||||
final List<AgentLimitData> limits = limitsByAgentId.getOrDefault(agent.getId(), new ArrayList<>());
|
||||
final List<AgentGuarantorData> guarantors = guarantorsByAgentId.getOrDefault(agent.getId(), new ArrayList<>());
|
||||
result.add(AgentData.instance(agent.getId(), agent.getClientId(), agent.getClientName(), agent.getAgentCode(),
|
||||
agent.getAgentNameId(), agent.getAgentAddress1(), agent.getAgentAddress2(), agent.getAgentCity(),
|
||||
agent.getAgentPinCode(), agent.getAgentTelephone(), agent.getAgentSavingAccountType(),
|
||||
agent.getAgentSavingAccountNo(), agent.getAgentOpeningDate(), agent.getAgentBirthDate(),
|
||||
agent.getAgentCurrentBalance(), agent.getAgentOpeningBalance(), agent.getHomeBranch(), agent.getSavingBranch(),
|
||||
agent.getTdsApplicable(), agent.getTdsPercent(), agent.getCurrentTds(), agent.getTdsAmount(),
|
||||
agent.getTransferBranch(), agent.getTransferAmountAccountType(), agent.getTransferAccountNo(),
|
||||
agent.getCommissionCalculation(), agent.getLimit(), agent.getMachineRent(), agent.getAgentSecurityBranch(),
|
||||
agent.getAgentSecurityAccountType(), agent.getAgentSecurityAccountNo(), agent.getCommission(),
|
||||
agent.getOtherCommission(), agent.getAgentDeposit(), agent.getFestivalAdvance(), agent.getOtherDeduction(),
|
||||
limits, guarantors));
|
||||
result.add(agent.toBuilder().guarantors(guarantorsByAgentId.getOrDefault(agent.getId(), new ArrayList<>())).build());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
+7
@@ -26,4 +26,11 @@ public interface AgentWritePlatformService {
|
||||
CommandProcessingResult createAgent(JsonCommand command);
|
||||
|
||||
CommandProcessingResult updateAgent(Long agentId, JsonCommand command);
|
||||
|
||||
/**
|
||||
* Ends the relationship with an agent. Refused while they still have accounts assigned, hold
|
||||
* collected cash, or are owed commission - each of those would be left with nobody responsible
|
||||
* for it. Distinct from deactivating, which stops an agent collecting and is never blocked.
|
||||
*/
|
||||
CommandProcessingResult closeAgent(Long agentId, JsonCommand command);
|
||||
}
|
||||
|
||||
+218
-19
@@ -21,8 +21,7 @@ package org.apache.fineract.organisation.agent.service;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import jakarta.persistence.PersistenceException;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -34,13 +33,24 @@ import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
||||
import org.apache.fineract.organisation.agent.domain.AgentGuarantor;
|
||||
import org.apache.fineract.organisation.agent.domain.AgentLimit;
|
||||
import org.apache.fineract.organisation.agent.domain.AgentRepository;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentCannotBeClosedException;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentSavingsAccountClientMismatchException;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentSavingsAccountNotFoundException;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentSavingsAccountNotOperativeException;
|
||||
import org.apache.fineract.organisation.agent.serialization.AgentCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentGlAccountUnusableException;
|
||||
import org.apache.fineract.organisation.office.domain.Office;
|
||||
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.orm.jpa.JpaSystemException;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -51,6 +61,10 @@ public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePla
|
||||
private final AgentCommandFromApiJsonDeserializer fromApiJsonDeserializer;
|
||||
private final AgentRepository agentRepository;
|
||||
private final ClientRepositoryWrapper clientRepositoryWrapper;
|
||||
private final OfficeRepositoryWrapper officeRepositoryWrapper;
|
||||
private final GLAccountRepository glAccountRepository;
|
||||
private final PartyRepository partyRepository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -62,10 +76,12 @@ public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePla
|
||||
final Long clientId = command.longValueOfParameterNamed("clientId");
|
||||
final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
|
||||
|
||||
validateSavingsAccount(clientId, command);
|
||||
|
||||
final String agentCode = generateNextAgentCode();
|
||||
final Agent agent = Agent.fromJson(client, agentCode, command);
|
||||
final Agent agent = Agent.fromJson(client, agentCode, officeOf(client, command), command);
|
||||
agent.setGlAccount(glAccountOf(command, null));
|
||||
|
||||
addLimitsFromCommand(agent, command);
|
||||
addGuarantorsFromCommand(agent, command);
|
||||
|
||||
this.agentRepository.saveAndFlush(agent);
|
||||
@@ -92,18 +108,32 @@ public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePla
|
||||
this.fromApiJsonDeserializer.validateForUpdate(command.json());
|
||||
|
||||
final Agent agentForUpdate = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
|
||||
|
||||
// Checked against whichever client the agent will belong to after this update, not the
|
||||
// one they belonged to before - otherwise moving an agent to a new client would carry the
|
||||
// old client's account across unchallenged.
|
||||
final Long owningClientId = command.parameterExists("clientId") ? command.longValueOfParameterNamed("clientId")
|
||||
: agentForUpdate.clientId();
|
||||
validateSavingsAccount(owningClientId, command);
|
||||
|
||||
final Map<String, Object> changesOnly = agentForUpdate.update(command);
|
||||
|
||||
if (changesOnly.containsKey("clientId")) {
|
||||
final Long newClientId = (Long) changesOnly.get("clientId");
|
||||
final Client newClient = this.clientRepositoryWrapper.findOneWithNotFoundDetection(newClientId);
|
||||
agentForUpdate.changeClient(newClient);
|
||||
// The branch follows the client unless the caller named one - see officeOf.
|
||||
agentForUpdate.setOffice(officeOf(newClient, command));
|
||||
changesOnly.put("officeId", agentForUpdate.officeId());
|
||||
} else if (command.isChangeInLongParameterNamed("officeId", agentForUpdate.officeId())) {
|
||||
final Long newOfficeId = command.longValueOfParameterNamed("officeId");
|
||||
agentForUpdate.setOffice(this.officeRepositoryWrapper.findOneWithNotFoundDetection(newOfficeId));
|
||||
changesOnly.put("officeId", newOfficeId);
|
||||
}
|
||||
|
||||
if (command.parameterExists("limits")) {
|
||||
agentForUpdate.resetLimits();
|
||||
addLimitsFromCommand(agentForUpdate, command);
|
||||
changesOnly.put("limits", command.jsonFragment("limits"));
|
||||
if (command.isChangeInLongParameterNamed("glAccountId", agentForUpdate.glAccountId())) {
|
||||
agentForUpdate.setGlAccount(glAccountOf(command, agentForUpdate.getId()));
|
||||
changesOnly.put("glAccountId", agentForUpdate.glAccountId());
|
||||
}
|
||||
|
||||
if (command.parameterExists("guarantors")) {
|
||||
@@ -126,20 +156,189 @@ public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePla
|
||||
}
|
||||
}
|
||||
|
||||
private void addLimitsFromCommand(final Agent agent, final JsonCommand command) {
|
||||
if (!command.parameterExists("limits")) {
|
||||
/**
|
||||
* Ends the relationship with an agent, refusing while anything is still outstanding.
|
||||
*
|
||||
* The three conditions are the three things that would be left with nobody responsible for them:
|
||||
* accounts still being collected from, cash the agent holds that has not reached a branch, and
|
||||
* commission earned but not paid. Each is reported by name and figure, so whoever is closing the
|
||||
* agent knows exactly what to settle first rather than being told only that it failed.
|
||||
*
|
||||
* Deactivating an agent is deliberately not guarded this way - see
|
||||
* {@link org.apache.fineract.organisation.agent.exception.AgentCannotBeClosedException}.
|
||||
*/
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult closeAgent(final Long agentId, final JsonCommand command) {
|
||||
|
||||
final Agent agent = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
|
||||
|
||||
if (agent.isClosed()) {
|
||||
throw new AgentCannotBeClosedException(agent.getAgentCode(), "it is already closed");
|
||||
}
|
||||
|
||||
final Long openAccounts = this.partyRepository.countActivePartiesOfAgent(agentId);
|
||||
if (openAccounts != null && openAccounts > 0) {
|
||||
throw new AgentCannotBeClosedException(agent.getAgentCode(), openAccounts
|
||||
+ (openAccounts == 1 ? " account is" : " accounts are")
|
||||
+ " still assigned to them. Move them to another agent first.");
|
||||
}
|
||||
|
||||
if (agent.holdsCash()) {
|
||||
throw new AgentCannotBeClosedException(agent.getAgentCode(), "they still hold "
|
||||
+ agent.getAgentCurrentBalance().toPlainString()
|
||||
+ " in collected cash. Settle it to a branch first (Agent Tran).");
|
||||
}
|
||||
|
||||
if (agent.isOwedCommission()) {
|
||||
throw new AgentCannotBeClosedException(agent.getAgentCode(),
|
||||
"they are still owed " + agent.getCommission().toPlainString()
|
||||
+ " in commission. Pay it out first (Commission Posting).");
|
||||
}
|
||||
|
||||
agent.close(DateUtils.getBusinessLocalDate());
|
||||
this.agentRepository.saveAndFlush(agent);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(agentId).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Which branch this agent belongs to.
|
||||
*
|
||||
* The client's own office is the default, since that is where the person is on the bank's books.
|
||||
* A caller may still name a branch, because an agent can be registered at one office and collect
|
||||
* out of another - the batch runs filter by this, so it has to say where the agent actually
|
||||
* works rather than where their client record happens to sit.
|
||||
*
|
||||
* Resolved to an actual office row rather than taken as a name: an unknown id is refused here,
|
||||
* where a name that matched no branch used to be stored as happily as one that did.
|
||||
*/
|
||||
/**
|
||||
* The ledger account this agent's collections are to pass through, or null to leave them on the
|
||||
* scheme's shared one.
|
||||
*
|
||||
* Four things are asked of it, and each has a way of going wrong that would not show up until
|
||||
* money was moving. It has to exist. It has to be a detail account, because a header account is
|
||||
* a heading over others and carries no entries of its own. It has to be enabled, or every
|
||||
* posting through it would be refused. And it has to belong to no other agent, since two agents
|
||||
* on one account is the shared account again under a different name - the balance would say what
|
||||
* is outstanding without saying whose.
|
||||
*
|
||||
* What it deliberately does not check is the account's classification. Cash held by an agent is
|
||||
* an asset and the branch will almost always pick one, but where a bank chooses to carry it is
|
||||
* the bank's own accounting policy, and this is not the place to overrule it.
|
||||
*
|
||||
* @param forAgentId
|
||||
* the agent being changed, so an agent keeping its own account is not refused for
|
||||
* already holding it. Null when the agent does not exist yet.
|
||||
*/
|
||||
private GLAccount glAccountOf(final JsonCommand command, final Long forAgentId) {
|
||||
if (!command.parameterExists("glAccountId")) {
|
||||
return null;
|
||||
}
|
||||
final Long glAccountId = command.longValueOfParameterNamed("glAccountId");
|
||||
if (glAccountId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final GLAccount glAccount = this.glAccountRepository.findById(glAccountId)
|
||||
.orElseThrow(() -> new AgentGlAccountUnusableException(String.valueOf(glAccountId), "no such ledger account exists"));
|
||||
|
||||
if (glAccount.isHeaderAccount()) {
|
||||
throw new AgentGlAccountUnusableException(glAccount.getGlCode(), "it is a header account and carries no entries of its own");
|
||||
}
|
||||
if (glAccount.isDisabled()) {
|
||||
throw new AgentGlAccountUnusableException(glAccount.getGlCode(), "it is disabled");
|
||||
}
|
||||
|
||||
final Agent holder = this.agentRepository.findByGlAccountId(glAccountId);
|
||||
if (holder != null && !holder.getId().equals(forAgentId)) {
|
||||
throw new AgentGlAccountUnusableException(glAccount.getGlCode(), "agent " + holder.getAgentCode() + " already uses it");
|
||||
}
|
||||
|
||||
return glAccount;
|
||||
}
|
||||
|
||||
private Office officeOf(final Client client, final JsonCommand command) {
|
||||
if (command.parameterExists("officeId")) {
|
||||
final Long officeId = command.longValueOfParameterNamed("officeId");
|
||||
if (officeId != null) {
|
||||
return this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
|
||||
}
|
||||
}
|
||||
return client.getOffice();
|
||||
}
|
||||
|
||||
/**
|
||||
* The open states of a savings account, matching SavingsAccountRepository's own "non closed" set.
|
||||
* deposit_type_enum comes back too: 100 is a savings account, 200 a fixed deposit, 300 recurring.
|
||||
*/
|
||||
/**
|
||||
* The account an agent's commission is to be paid into, and enough about it to say whether it
|
||||
* can take one.
|
||||
*
|
||||
* Active only. An account still awaiting approval, or approved but never activated, takes no
|
||||
* credit at all - and one accepted here would fail silently on every commission run from then
|
||||
* on, leaving the agent unpaid with nothing on screen to say why.
|
||||
*/
|
||||
private static final String OPEN_ACCOUNT_SQL = "select a.client_id, p.deposit_type_enum, a.product_id from m_savings_account a "
|
||||
+ "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";
|
||||
|
||||
private static final int SAVINGS_DEPOSIT_TYPE = 100;
|
||||
|
||||
/**
|
||||
* Refuses a savings account that does not exist, or that belongs to somebody else.
|
||||
*
|
||||
* The agent's commission is paid into this account. Until this ran the number was free text, and
|
||||
* the live data showed what that costs: agent AG0001 carried "1234567890", which matches no
|
||||
* account in the database at all - so every commission recorded against it had nowhere to go.
|
||||
*
|
||||
* Enforced here rather than only on the screen, because the API can be called directly.
|
||||
*
|
||||
* Reads the owner's id rather than loading the account entity. Loading it pulled its product in
|
||||
* too, and saving the agent afterwards then failed for a fixed-deposit account with "The
|
||||
* attribute [id] of class FixedDepositProduct ... Updates are not allowed" - the agent's own
|
||||
* insert flushing a savings product it had merely looked at. Two columns are all this check
|
||||
* needs, and reading them touches no entity the transaction can try to write back.
|
||||
*/
|
||||
private void validateSavingsAccount(final Long expectedClientId, final JsonCommand command) {
|
||||
if (!command.parameterExists("agentSavingAccountNo")) {
|
||||
return;
|
||||
}
|
||||
final JsonArray limitsArray = command.arrayOfParameterNamed("limits");
|
||||
if (limitsArray == null) {
|
||||
final String accountNo = command.stringValueOfParameterNamed("agentSavingAccountNo");
|
||||
if (accountNo == null || accountNo.isBlank()) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < limitsArray.size(); i++) {
|
||||
final JsonObject limitJson = limitsArray.get(i).getAsJsonObject();
|
||||
final LocalDate fromDate = LocalDate.parse(limitJson.get("fromDate").getAsString());
|
||||
final LocalDate toDate = LocalDate.parse(limitJson.get("toDate").getAsString());
|
||||
final BigDecimal limit = limitJson.get("limit").getAsBigDecimal();
|
||||
agent.addLimit(new AgentLimit(fromDate, toDate, limit));
|
||||
|
||||
final List<Map<String, Object>> accounts = this.jdbcTemplate.queryForList(OPEN_ACCOUNT_SQL, accountNo); // NOSONAR
|
||||
if (accounts.isEmpty()) {
|
||||
throw new AgentSavingsAccountNotFoundException(accountNo);
|
||||
}
|
||||
|
||||
final Map<String, Object> account = accounts.get(0);
|
||||
final Long ownerId = ((Number) account.get("client_id")).longValue();
|
||||
if (!expectedClientId.equals(ownerId)) {
|
||||
throw new AgentSavingsAccountClientMismatchException(accountNo);
|
||||
}
|
||||
|
||||
// Commission is credited whenever a run posts, which only a savings account accepts.
|
||||
final int depositType = ((Number) account.get("deposit_type_enum")).intValue();
|
||||
if (depositType != SAVINGS_DEPOSIT_TYPE) {
|
||||
throw new AgentSavingsAccountNotOperativeException(accountNo, depositType == 200 ? "fixed deposit" : "recurring deposit");
|
||||
}
|
||||
|
||||
// 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))) {
|
||||
throw new AgentSavingsAccountNotOperativeException(accountNo, "DDS scheme");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -18,13 +18,16 @@
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.starter;
|
||||
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
|
||||
import org.apache.fineract.organisation.agent.domain.AgentRepository;
|
||||
import org.apache.fineract.organisation.agent.serialization.AgentCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.organisation.agent.service.AgentReadPlatformService;
|
||||
import org.apache.fineract.organisation.agent.service.AgentReadPlatformServiceImpl;
|
||||
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
|
||||
import org.apache.fineract.organisation.agent.service.AgentWritePlatformServiceJpaRepositoryImpl;
|
||||
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -42,7 +45,10 @@ public class OrganisationAgentConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(AgentWritePlatformService.class)
|
||||
public AgentWritePlatformService agentWritePlatformService(AgentCommandFromApiJsonDeserializer fromApiJsonDeserializer,
|
||||
AgentRepository agentRepository, ClientRepositoryWrapper clientRepositoryWrapper) {
|
||||
return new AgentWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, agentRepository, clientRepositoryWrapper);
|
||||
AgentRepository agentRepository, ClientRepositoryWrapper clientRepositoryWrapper,
|
||||
OfficeRepositoryWrapper officeRepositoryWrapper, GLAccountRepository glAccountRepository, PartyRepository partyRepository,
|
||||
JdbcTemplate jdbcTemplate) {
|
||||
return new AgentWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, agentRepository, clientRepositoryWrapper,
|
||||
officeRepositoryWrapper, glAccountRepository, partyRepository, jdbcTemplate);
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -24,7 +24,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.Collection;
|
||||
@@ -71,4 +73,20 @@ public class CommissionSlabsApiResource {
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("{slabId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Correct a DDS Commission Slab", description = "Optional fields: slabType, slabFrom, slabTo, "
|
||||
+ "commissionPercent. The corrected range is re-checked against the other slabs of its type and refused if it "
|
||||
+ "overlaps one of them.")
|
||||
public CommandProcessingResult update(@PathParam("slabId") @Parameter(description = "slabId") final Long slabId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.updateCommissionSlab(slabId) //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -28,4 +28,7 @@ public interface CommissionSlabRepository extends JpaRepository<CommissionSlab,
|
||||
|
||||
@Query("select s from CommissionSlab s where s.slabType = :slabType and :amount between s.slabFrom and s.slabTo")
|
||||
List<CommissionSlab> findMatchingSlabs(@Param("slabType") String slabType, @Param("amount") BigDecimal amount);
|
||||
|
||||
@Query("select s from CommissionSlab s where s.slabType = :slabType order by s.slabFrom")
|
||||
List<CommissionSlab> findByType(@Param("slabType") String slabType);
|
||||
}
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.organisation.commissionslab.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "COMMISSIONSLAB", action = "UPDATE")
|
||||
public class UpdateCommissionSlabCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final CommissionSlabWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public UpdateCommissionSlabCommandHandler(final CommissionSlabWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.updateCommissionSlab(command.entityId(), command);
|
||||
}
|
||||
}
|
||||
+46
-2
@@ -41,10 +41,14 @@ import org.springframework.stereotype.Component;
|
||||
@Component
|
||||
public final class CommissionSlabCommandFromApiJsonDeserializer {
|
||||
|
||||
private static final Set<String> SUPPORTED_SLAB_TYPES = new HashSet<>(Arrays.asList("ACCOUNT", "COMMISSION"));
|
||||
// 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 Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("slabs", "dateFormat", "locale"));
|
||||
|
||||
private static final Set<String> SUPPORTED_UPDATE_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList("slabType", "slabFrom", "slabTo", "commissionPercent", "dateFormat", "locale"));
|
||||
|
||||
private static final Set<String> SUPPORTED_SLAB_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList("slabType", "slabFrom", "slabTo", "commissionPercent"));
|
||||
|
||||
@@ -83,7 +87,7 @@ public final class CommissionSlabCommandFromApiJsonDeserializer {
|
||||
|
||||
final String slabType = extractString(slabJson, "slabType");
|
||||
v.reset().parameterAtIndexArray("slabType", i).value(slabType).notBlank()
|
||||
.isOneOfTheseStringValues(SUPPORTED_SLAB_TYPES.toArray());
|
||||
.isOneOfTheseStringValues(SUPPORTED_SLAB_TYPES);
|
||||
v.reset().parameterAtIndexArray("slabFrom", i).value(extractBigDecimal(slabJson, "slabFrom")).notNull()
|
||||
.zeroOrPositiveAmount();
|
||||
v.reset().parameterAtIndexArray("slabTo", i).value(extractBigDecimal(slabJson, "slabTo")).notNull()
|
||||
@@ -96,6 +100,46 @@ public final class CommissionSlabCommandFromApiJsonDeserializer {
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
}
|
||||
|
||||
/**
|
||||
* A correction to one existing slab - a flat object, not the array the create endpoint takes,
|
||||
* because only one row is ever being fixed.
|
||||
*/
|
||||
public void validateForUpdate(final String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
throw new InvalidJsonException();
|
||||
}
|
||||
|
||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
|
||||
|
||||
}.getType();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_UPDATE_PARAMETERS);
|
||||
|
||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("commissionslab");
|
||||
|
||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists("slabType", element)) {
|
||||
v.reset().parameter("slabType").value(this.fromApiJsonHelper.extractStringNamed("slabType", element)).notBlank()
|
||||
.isOneOfTheseStringValues(SUPPORTED_SLAB_TYPES);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("slabFrom", element)) {
|
||||
v.reset().parameter("slabFrom").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("slabFrom", element))
|
||||
.notNull().zeroOrPositiveAmount();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("slabTo", element)) {
|
||||
v.reset().parameter("slabTo").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("slabTo", element)).notNull()
|
||||
.zeroOrPositiveAmount();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("commissionPercent", element)) {
|
||||
v.reset().parameter("commissionPercent")
|
||||
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("commissionPercent", element)).notNull()
|
||||
.zeroOrPositiveAmount();
|
||||
}
|
||||
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
}
|
||||
|
||||
private String extractString(final JsonObject json, final String paramName) {
|
||||
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsString() : null;
|
||||
}
|
||||
|
||||
+2
@@ -24,4 +24,6 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
public interface CommissionSlabWritePlatformService {
|
||||
|
||||
CommandProcessingResult createCommissionSlabs(JsonCommand command);
|
||||
|
||||
CommandProcessingResult updateCommissionSlab(Long slabId, JsonCommand command);
|
||||
}
|
||||
|
||||
+56
-2
@@ -23,6 +23,7 @@ import com.google.gson.JsonObject;
|
||||
import jakarta.persistence.PersistenceException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||
@@ -32,6 +33,8 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuild
|
||||
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
|
||||
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlab;
|
||||
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
|
||||
import org.apache.fineract.organisation.commissionslab.exception.CommissionSlabNotFoundException;
|
||||
import org.apache.fineract.organisation.commissionslab.exception.OverlappingCommissionSlabException;
|
||||
import org.apache.fineract.organisation.commissionslab.serialization.CommissionSlabCommandFromApiJsonDeserializer;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.orm.jpa.JpaSystemException;
|
||||
@@ -55,8 +58,14 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
|
||||
final JsonArray slabsArray = command.arrayOfParameterNamed("slabs");
|
||||
for (int i = 0; i < slabsArray.size(); i++) {
|
||||
final JsonObject slabJson = slabsArray.get(i).getAsJsonObject();
|
||||
slabs.add(new CommissionSlab(slabJson.get("slabType").getAsString(), slabJson.get("slabFrom").getAsBigDecimal(),
|
||||
slabJson.get("slabTo").getAsBigDecimal(), slabJson.get("commissionPercent").getAsBigDecimal()));
|
||||
final CommissionSlab slab = new CommissionSlab(slabJson.get("slabType").getAsString(),
|
||||
slabJson.get("slabFrom").getAsBigDecimal(), slabJson.get("slabTo").getAsBigDecimal(),
|
||||
slabJson.get("commissionPercent").getAsBigDecimal());
|
||||
// Checked against what is already stored and against the rest of this request, since one
|
||||
// submission can just as easily carry two slabs that cover the same figure.
|
||||
rejectIfOverlapping(slab, this.commissionSlabRepository.findByType(slab.getSlabType()));
|
||||
rejectIfOverlapping(slab, slabs);
|
||||
slabs.add(slab);
|
||||
}
|
||||
|
||||
this.commissionSlabRepository.saveAllAndFlush(slabs);
|
||||
@@ -75,4 +84,49 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
|
||||
"Unknown data integrity issue with resource: " + ExceptionUtils.getRootCauseMessage(dve));
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult updateCommissionSlab(final Long slabId, final JsonCommand command) {
|
||||
|
||||
this.fromApiJsonDeserializer.validateForUpdate(command.json());
|
||||
|
||||
final CommissionSlab slab = this.commissionSlabRepository.findById(slabId)
|
||||
.orElseThrow(() -> new CommissionSlabNotFoundException(slabId));
|
||||
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
|
||||
// easily create an overlap as a new row can - and the slab being corrected must not be
|
||||
// compared with itself.
|
||||
if (!changes.isEmpty()) {
|
||||
final List<CommissionSlab> others = this.commissionSlabRepository.findByType(slab.getSlabType()).stream()
|
||||
.filter(other -> !other.getId().equals(slab.getId())).toList();
|
||||
rejectIfOverlapping(slab, others);
|
||||
this.commissionSlabRepository.saveAndFlush(slab);
|
||||
}
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(slabId).with(changes).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a slab whose range overlaps one already covering part of the same figures.
|
||||
*
|
||||
* Two slabs matching the same collection volume leave the rate up to whichever row the query
|
||||
* returns first, so an agent's percentage could change between runs with nothing in the data to
|
||||
* 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.
|
||||
*/
|
||||
private void rejectIfOverlapping(final CommissionSlab candidate, final List<CommissionSlab> existing) {
|
||||
for (final CommissionSlab other : existing) {
|
||||
if (!other.getSlabType().equals(candidate.getSlabType())) {
|
||||
continue;
|
||||
}
|
||||
final boolean overlaps = candidate.getSlabFrom().compareTo(other.getSlabTo()) <= 0
|
||||
&& candidate.getSlabTo().compareTo(other.getSlabFrom()) >= 0;
|
||||
if (overlaps) {
|
||||
throw new OverlappingCommissionSlabException(candidate.getSlabType(), candidate.getSlabFrom(),
|
||||
candidate.getSlabTo(), other.getSlabFrom(), other.getSlabTo());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+416
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounting;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
|
||||
import org.apache.fineract.accounting.journalentry.domain.JournalEntry;
|
||||
import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository;
|
||||
import org.apache.fineract.accounting.journalentry.domain.JournalEntryType;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.organisation.office.domain.Office;
|
||||
import org.apache.fineract.portfolio.PortfolioProductType;
|
||||
import org.apache.fineract.portfolio.dds.accounting.exception.DdsLedgerNotConfiguredException;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountSubStatusEnum;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
|
||||
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Where DDS money lands in the bank's own books.
|
||||
*
|
||||
* DDS used to keep its own balances and nothing else: a customer's money, an agent's commission and
|
||||
* the cash an agent was carrying were all numbers on DDS tables that the bank's books never saw. This
|
||||
* is the one place that changes - every DDS movement that is real money goes through here, becomes a
|
||||
* transaction on a real account, and leaves a matching pair of journal entries behind.
|
||||
*
|
||||
* <p>
|
||||
* The five movements and the entries each makes:
|
||||
*
|
||||
* <pre>
|
||||
* Agent collects from a customer DR Agent Cash CR the account's control
|
||||
* Agent settles at a branch DR Cash at Vault CR Agent Cash
|
||||
* One customer pays another DR their control CR the other's control
|
||||
* Interest is posted DR Interest on Savings CR the account's control
|
||||
* Commission is paid DR Commission Expense CR the account's control
|
||||
* </pre>
|
||||
*
|
||||
* The savings module's own accounting is deliberately skipped on these deposits. Left to itself it
|
||||
* debits the product's fund source - the entry an ordinary counter deposit makes, "cash came in over
|
||||
* the counter". None of these is that: a collection is cash that reached an agent rather than a till,
|
||||
* and interest and commission are expenses the bank incurred with no cash arriving at all. So the
|
||||
* deposit is recorded without its accounting and the true pair is written here instead.
|
||||
*
|
||||
* <p>
|
||||
* Every account is looked up rather than named in code - the ledger accounts from settings a bank can
|
||||
* repoint at accounts of their own, and each customer's control account from the product's own
|
||||
* mapping. Nothing here is written if any of them is missing: it throws, and the caller's whole
|
||||
* transaction rolls back. Money that moves without an entry behind it is worse than money that has
|
||||
* not moved, because the balance changes and the books do not.
|
||||
*/
|
||||
@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";
|
||||
|
||||
/** Where a matured deposit waits while it is on its way to another bank. Set by 0494. */
|
||||
public static final String MATURED_PAYABLE = "dds-matured-payable-account";
|
||||
|
||||
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";
|
||||
|
||||
/**
|
||||
* product_type 2 is savings. financial_account_type 2 is the control account - what the customer
|
||||
* is owed - and 1 is the fund source, the till or vault cash moves through.
|
||||
*/
|
||||
private static final String PRODUCT_ACCOUNT_SQL = "select gl_account_id from acc_product_mapping "
|
||||
+ "where product_id = ? and product_type = 2 and financial_account_type = ? and payment_type is null";
|
||||
|
||||
private static final int CONTROL_ACCOUNT = 2;
|
||||
private static final int FUND_SOURCE = 1;
|
||||
|
||||
/** What interest paid to a customer costs the bank. */
|
||||
private static final int INTEREST_ON_SAVINGS = 3;
|
||||
|
||||
private final SavingsAccountAssembler savingsAccountAssembler;
|
||||
private final SavingsAccountDomainService savingsAccountDomainService;
|
||||
private final JournalEntryRepository journalEntryRepository;
|
||||
private final GLAccountRepository glAccountRepository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* Credits a customer's own deposit account with what an agent collected from them.
|
||||
*
|
||||
* The money is real from the customer's side the moment the agent takes it, so it lands on their
|
||||
* account now rather than when the agent gets round to a branch. What the bank does not have yet
|
||||
* is the cash itself - it is in the agent's bag - which is what the Agent Cash side records.
|
||||
*/
|
||||
public SavingsAccountTransaction collect(final Long savingsAccountId, final LocalDate date, final BigDecimal amount,
|
||||
final String description, final GLAccount agentCashAccount) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
||||
postPair(account, deposit, date, amount, agentCashOr(agentCashAccount), controlAccountOf(account), description);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private GLAccount agentCashOr(final GLAccount agentCashAccount) {
|
||||
return agentCashAccount != null ? agentCashAccount : configured(AGENT_CASH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits a customer's account with interest their deposits have earned.
|
||||
*
|
||||
* The interest is worked out by the DDS run on the daily product, not by the savings product -
|
||||
* which is why the product carries a zero rate - but it is paid like any other interest: an
|
||||
* expense to the bank, and more owed to the customer.
|
||||
*/
|
||||
public SavingsAccountTransaction postInterest(final Long savingsAccountId, final LocalDate date, final BigDecimal amount,
|
||||
final String description) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
||||
postPair(account, deposit, date, amount, productAccount(account.productId(), INTEREST_ON_SAVINGS), controlAccountOf(account),
|
||||
description);
|
||||
return deposit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes back interest a customer has been credited but, on closing early, is not entitled to.
|
||||
*
|
||||
* The exact opposite of {@link #postInterest}: the bank owes the customer less, and its interest
|
||||
* expense is reduced by what it turned out not to owe. It is written as its own movement rather
|
||||
* than by quietly paying out less, so the passbook shows the interest that was credited and the
|
||||
* part that came back - a customer who is paid a smaller figure than their last statement showed
|
||||
* can see exactly why.
|
||||
*
|
||||
* <pre>
|
||||
* DR the account's control the customer's balance comes down
|
||||
* CR Interest on Savings the bank's interest expense is reduced
|
||||
* </pre>
|
||||
*/
|
||||
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);
|
||||
|
||||
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account),
|
||||
productAccount(account.productId(), INTEREST_ON_SAVINGS), description, null, account.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves money from one customer's account to another's.
|
||||
*
|
||||
* The bank is no better or worse off - it owed the money before and owes it after, just to
|
||||
* somebody else - so the two halves fall on the same control account and cancel. What changes is
|
||||
* which customer's balance holds it, and that is what the two account transactions record.
|
||||
*/
|
||||
public void transfer(final Long fromSavingsAccountId, final Long toSavingsAccountId, final LocalDate date, final BigDecimal amount,
|
||||
final String description) {
|
||||
final SavingsAccount from = load(fromSavingsAccountId);
|
||||
final SavingsAccount to = load(toSavingsAccountId);
|
||||
|
||||
// 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 deposit = depositWithoutAccounting(to, date, amount);
|
||||
|
||||
postPair(to, deposit, date, amount, controlAccountOf(from), controlAccountOf(to), description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pays a matured deposit into another account of the customer's at this bank.
|
||||
*
|
||||
* The whole balance leaves the DDS account and lands on theirs. Both are the bank's own accounts,
|
||||
* so nothing leaves the bank - it owes the same money to the same person, in a different place -
|
||||
* which is why this is the same movement as a transfer between two customers.
|
||||
*/
|
||||
public void payOutToOwnAccount(final Long fromSavingsAccountId, final Long toSavingsAccountId, final LocalDate date,
|
||||
final BigDecimal amount, final String description) {
|
||||
transfer(fromSavingsAccountId, toSavingsAccountId, date, amount, description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a matured deposit off the customer's account and parks it, ready to be sent to another
|
||||
* bank.
|
||||
*
|
||||
* The money leaves the DDS account today; the NEFT or IMPS clears later, or fails and is tried
|
||||
* again. Sending it straight out would leave a window where it has left one account and arrived
|
||||
* at none, and a failed transfer would leave nothing accounting for it at all. So it waits in the
|
||||
* payable account, which says at any moment exactly what the bank is holding and still owes.
|
||||
*
|
||||
* <pre>
|
||||
* DR the account's control the customer's balance goes
|
||||
* CR Matured Deposits Payable and the bank now owes it to them
|
||||
* </pre>
|
||||
*/
|
||||
public void payOutToPayable(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);
|
||||
|
||||
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), configured(MATURED_PAYABLE),
|
||||
description, null, account.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings a balance a party already had onto its newly opened account.
|
||||
*
|
||||
* These are customers whose money DDS held before it opened real accounts: the bank has the cash -
|
||||
* it was collected and banked over months - but its own books carry no record of either the cash
|
||||
* or what it owes them. This writes both sides at once, which is what an opening entry is for.
|
||||
*
|
||||
* <pre>
|
||||
* DR the product's fund source the cash the bank already holds
|
||||
* CR the account's control what it owes the customer
|
||||
* </pre>
|
||||
*
|
||||
* The debit assumes the cash reached the bank rather than still sitting with an agent. That holds
|
||||
* for a settled balance and is the ordinary case; a bank whose agents were mid-round when this ran
|
||||
* should reconcile it. It is the one assumption here that an accountant, not this code, should
|
||||
* confirm.
|
||||
*/
|
||||
public SavingsAccountTransaction openingBalance(final Long savingsAccountId, final LocalDate date, final BigDecimal amount,
|
||||
final String description) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
||||
postPair(account, deposit, date, amount, productAccount(account.productId(), FUND_SOURCE), controlAccountOf(account), description);
|
||||
return deposit;
|
||||
}
|
||||
|
||||
/** Credits an agent's own savings account with commission the bank owes them. */
|
||||
public SavingsAccountTransaction payCommission(final Long savingsAccountId, final LocalDate date, final BigDecimal amount,
|
||||
final String description) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
||||
postPair(account, deposit, date, amount, configured(COMMISSION_EXPENSE), controlAccountOf(account), description);
|
||||
return deposit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks withdrawals on a scheme account, leaving deposits alone.
|
||||
*
|
||||
* A DDS account is a savings account, and without this a customer can empty a twelve-month
|
||||
* scheme at the counter the day after opening it - which is the one thing a daily deposit scheme
|
||||
* exists to prevent. Worse, that withdrawal happens outside DDS: the scheme balance and the
|
||||
* account balance part company, every report misstates the book, and the maturity payout later
|
||||
* fails for want of funds.
|
||||
*
|
||||
* Deposits are untouched, so the agent collects as before and interest posts as before. Only the
|
||||
* way out is closed, and only from outside - DDS lifts it for its own payouts and transfers.
|
||||
*/
|
||||
public void lockDebits(final Long savingsAccountId) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
if (!isDebitBlocked(account)) {
|
||||
account.blockDebits(account.getSubStatus());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifts the block for a movement DDS is making itself, saying whether it had to.
|
||||
*
|
||||
* The caller puts it back with {@link #relockDebits}. An account that was not blocked - one
|
||||
* opened before the block existed, say - is left as it is rather than being blocked for the
|
||||
* first time on its way out of the scheme.
|
||||
*/
|
||||
private boolean unlockDebitsIfBlocked(final SavingsAccount account) {
|
||||
if (!isDebitBlocked(account)) {
|
||||
return false;
|
||||
}
|
||||
account.unblockDebits();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Puts the block back after one of DDS's own withdrawals, if this call was what lifted it. */
|
||||
private void relockDebits(final SavingsAccount account, final boolean wasBlocked) {
|
||||
if (wasBlocked) {
|
||||
account.blockDebits(account.getSubStatus());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isDebitBlocked(final SavingsAccount account) {
|
||||
return SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(SavingsAccountSubStatusEnum.fromInt(account.getSubStatus()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
private SavingsAccount load(final Long savingsAccountId) {
|
||||
return this.savingsAccountAssembler.assembleFrom(savingsAccountId, this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
}
|
||||
|
||||
private SavingsAccountTransaction depositWithoutAccounting(final SavingsAccount account, final LocalDate date,
|
||||
final BigDecimal amount) {
|
||||
return this.savingsAccountDomainService.handleDeposit(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, false, true,
|
||||
this.savingsAccountAssembler.getPivotConfigStatus(), true);
|
||||
}
|
||||
|
||||
private void postPair(final SavingsAccount account, final SavingsAccountTransaction deposit, final LocalDate date,
|
||||
final BigDecimal amount, final GLAccount debit, final GLAccount credit, final String description) {
|
||||
postPair(account.office(), account.getCurrency().getCode(), date, amount, debit, credit, description, deposit.getId(),
|
||||
account.getId());
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 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);
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
final List<Long> ids = this.jdbcTemplate.queryForList(PRODUCT_ACCOUNT_SQL, Long.class, productId, financialAccountType); // NOSONAR
|
||||
final String what = financialAccountType == CONTROL_ACCOUNT ? "control account" : "fund source";
|
||||
if (ids.isEmpty()) {
|
||||
throw new DdsLedgerNotConfiguredException(what + " for savings product " + productId);
|
||||
}
|
||||
return this.glAccountRepository.findById(ids.get(0))
|
||||
.orElseThrow(() -> new DdsLedgerNotConfiguredException(what + " for savings product " + productId));
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounting.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a DDS movement cannot say which ledger accounts it belongs in.
|
||||
*
|
||||
* The caller's whole transaction rolls back rather than the money moving anyway. A balance that
|
||||
* changes with no entry behind it is the harder thing to unpick: nobody finds out until something is
|
||||
* reconciled, and by then there is no record of what should have been written.
|
||||
*/
|
||||
public class DdsLedgerNotConfiguredException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public DdsLedgerNotConfiguredException(final String missing) {
|
||||
super("error.msg.dds.ledger.not.configured",
|
||||
"This cannot be posted because " + missing + " is not set up. Set it before posting DDS transactions.", missing);
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.api;
|
||||
|
||||
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.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.Collection;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.data.DdsAccountTypeData;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/dds/accounttypes")
|
||||
@Component
|
||||
@Tag(name = "DDS Account Types", description = "A DDS Account Type is a scheme variant a Party account is opened under - group, code, description and auto-interest-posting settings. Master data only: it holds no deposit terms, and interest is accrued off each Party's own rate by the DDS batch job.")
|
||||
@RequiredArgsConstructor
|
||||
public class DdsAccountTypesApiResource {
|
||||
|
||||
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DDSACCOUNTTYPE";
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final DdsAccountTypeReadPlatformService readPlatformService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve DDS Account Types", description = "Returns the list of DDS account types, ordered by code.")
|
||||
public Collection<DdsAccountTypeData> retrieveAll() {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
return readPlatformService.retrieveAll();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{accountTypeId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve a DDS Account Type", description = "Returns the details of a single DDS account type.")
|
||||
public DdsAccountTypeData retrieveOne(
|
||||
@PathParam("accountTypeId") @Parameter(description = "accountTypeId") final Long accountTypeId) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
return readPlatformService.retrieveOne(accountTypeId);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Create a DDS Account Type", description = "Mandatory fields: accountTypeGroup, accountTypeCode, "
|
||||
+ "accountTypeDescription\n" + "\n" + "Optional fields: autoInterestPost, autoInterestPostWhen\n" + "\n"
|
||||
+ "accountTypeCode is stored upper-cased, so it stays a single identity regardless of how it was typed.")
|
||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.createDdsAccountType() //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("{accountTypeId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Update a DDS Account Type", description = "Updates the details of a DDS account type.")
|
||||
public CommandProcessingResult update(
|
||||
@PathParam("accountTypeId") @Parameter(description = "accountTypeId") final Long accountTypeId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.updateDdsAccountType(accountTypeId) //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.domain;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
public interface DdsAccountTypeRepository extends JpaRepository<DdsAccountType, Long> {
|
||||
|
||||
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")
|
||||
List<DdsAccountType> findDueForAutoInterestPost();
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "DDSACCOUNTTYPE", action = "CREATE")
|
||||
public class CreateDdsAccountTypeCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final DdsAccountTypeWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public CreateDdsAccountTypeCommandHandler(final DdsAccountTypeWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.createDdsAccountType(command);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "DDSACCOUNTTYPE", action = "UPDATE")
|
||||
public class UpdateDdsAccountTypeCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final DdsAccountTypeWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public UpdateDdsAccountTypeCommandHandler(final DdsAccountTypeWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.updateDdsAccountType(command.entityId(), command);
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.serialization;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
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;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public final class DdsAccountTypeCommandFromApiJsonDeserializer {
|
||||
|
||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("accountTypeGroup", "accountTypeCode",
|
||||
"accountTypeDescription", "autoInterestPost", "autoInterestPostWhen", "autoInterestPostDay", "maturityAutoClose", "active",
|
||||
"prematureLockinMonths", "prematureInterestReduction", "prematureAgentCommissionPenalty",
|
||||
"dateFormat", "locale"));
|
||||
|
||||
/** A rate, not an amount - nothing here may exceed the whole of what it is taken from. */
|
||||
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
|
||||
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
|
||||
public DdsAccountTypeCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
|
||||
this.fromApiJsonHelper = fromApiJsonHelper;
|
||||
}
|
||||
|
||||
public void validateForCreate(final String json) {
|
||||
final JsonElement element = parseAndCheckParameters(json);
|
||||
|
||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("ddsaccounttype");
|
||||
|
||||
v.reset().parameter("accountTypeGroup").value(this.fromApiJsonHelper.extractStringNamed("accountTypeGroup", element)).notBlank()
|
||||
.notExceedingLengthOf(60);
|
||||
v.reset().parameter("accountTypeCode").value(this.fromApiJsonHelper.extractStringNamed("accountTypeCode", element)).notBlank()
|
||||
.notExceedingLengthOf(20);
|
||||
v.reset().parameter("accountTypeDescription")
|
||||
.value(this.fromApiJsonHelper.extractStringNamed("accountTypeDescription", element)).notBlank()
|
||||
.notExceedingLengthOf(200);
|
||||
|
||||
validateOptionalFields(element, v);
|
||||
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
}
|
||||
|
||||
public void validateForUpdate(final String json) {
|
||||
final JsonElement element = parseAndCheckParameters(json);
|
||||
|
||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("ddsaccounttype");
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists("accountTypeGroup", element)) {
|
||||
v.reset().parameter("accountTypeGroup").value(this.fromApiJsonHelper.extractStringNamed("accountTypeGroup", element))
|
||||
.notBlank().notExceedingLengthOf(60);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("accountTypeCode", element)) {
|
||||
v.reset().parameter("accountTypeCode").value(this.fromApiJsonHelper.extractStringNamed("accountTypeCode", element)).notBlank()
|
||||
.notExceedingLengthOf(20);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("accountTypeDescription", element)) {
|
||||
v.reset().parameter("accountTypeDescription")
|
||||
.value(this.fromApiJsonHelper.extractStringNamed("accountTypeDescription", element)).notBlank()
|
||||
.notExceedingLengthOf(200);
|
||||
}
|
||||
|
||||
validateOptionalFields(element, v);
|
||||
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
}
|
||||
|
||||
private JsonElement parseAndCheckParameters(final String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
throw new InvalidJsonException();
|
||||
}
|
||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
|
||||
|
||||
}.getType();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
|
||||
return this.fromApiJsonHelper.parse(json);
|
||||
}
|
||||
|
||||
private void validateOptionalFields(final JsonElement element, final DataValidatorBuilder v) {
|
||||
if (this.fromApiJsonHelper.parameterExists("autoInterestPostWhen", element)) {
|
||||
v.reset().parameter("autoInterestPostWhen")
|
||||
.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.
|
||||
if (this.fromApiJsonHelper.parameterExists("prematureLockinMonths", element)) {
|
||||
v.reset().parameter("prematureLockinMonths")
|
||||
.value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("prematureLockinMonths", element)).ignoreIfNull()
|
||||
.inMinMaxRange(0, 600);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("prematureInterestReduction", element)) {
|
||||
v.reset().parameter("prematureInterestReduction")
|
||||
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("prematureInterestReduction", element))
|
||||
.ignoreIfNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("prematureAgentCommissionPenalty", element)) {
|
||||
v.reset().parameter("prematureAgentCommissionPenalty")
|
||||
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("prematureAgentCommissionPenalty", element))
|
||||
.ignoreIfNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
|
||||
}
|
||||
}
|
||||
|
||||
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
|
||||
if (!dataValidationErrors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
|
||||
dataValidationErrors);
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.service;
|
||||
|
||||
import java.util.Collection;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.data.DdsAccountTypeData;
|
||||
|
||||
public interface DdsAccountTypeReadPlatformService {
|
||||
|
||||
Collection<DdsAccountTypeData> retrieveAll();
|
||||
|
||||
DdsAccountTypeData retrieveOne(Long accountTypeId);
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.service;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.data.DdsAccountTypeData;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.exception.DdsAccountTypeNotFoundException;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeReadPlatformService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
private static final DdsAccountTypeMapper MAPPER = new DdsAccountTypeMapper();
|
||||
|
||||
private static final class DdsAccountTypeMapper implements RowMapper<DdsAccountTypeData> {
|
||||
|
||||
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.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.is_active as active, "
|
||||
+ " t.created_date as createdDate, t.lastmodified_date as lastModifiedDate "
|
||||
+ " from m_dds_account_type t";
|
||||
}
|
||||
|
||||
@Override
|
||||
public DdsAccountTypeData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||
return DdsAccountTypeData.builder() //
|
||||
.id(rs.getLong("id")) //
|
||||
.accountTypeGroup(rs.getString("accountTypeGroup")) //
|
||||
.accountTypeCode(rs.getString("accountTypeCode")) //
|
||||
.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")) //
|
||||
.active(rs.getBoolean("active")) //
|
||||
.createdDate(localDateTime(rs, "createdDate")) //
|
||||
.lastModifiedDate(localDateTime(rs, "lastModifiedDate")) //
|
||||
.build();
|
||||
}
|
||||
|
||||
/** JdbcSupport has no LocalDateTime helper, and these audit columns are nullable. */
|
||||
private LocalDateTime localDateTime(final ResultSet rs, final String columnName) throws SQLException {
|
||||
final Timestamp timestamp = rs.getTimestamp(columnName);
|
||||
return timestamp == null ? null : timestamp.toLocalDateTime();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<DdsAccountTypeData> retrieveAll() {
|
||||
final String sql = "select " + MAPPER.schema() + " order by t.account_type_code";
|
||||
return this.jdbcTemplate.query(sql, MAPPER); // NOSONAR
|
||||
}
|
||||
|
||||
@Override
|
||||
public DdsAccountTypeData retrieveOne(final Long accountTypeId) {
|
||||
try {
|
||||
final String sql = "select " + MAPPER.schema() + " where t.id = ?";
|
||||
return this.jdbcTemplate.queryForObject(sql, MAPPER, accountTypeId); // NOSONAR
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
throw new DdsAccountTypeNotFoundException(accountTypeId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-25
@@ -16,33 +16,14 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.data;
|
||||
package org.apache.fineract.portfolio.dds.accounttype.service;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
|
||||
@Getter
|
||||
public final class AgentLimitData implements Serializable {
|
||||
public interface DdsAccountTypeWritePlatformService {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
CommandProcessingResult createDdsAccountType(JsonCommand command);
|
||||
|
||||
private final Long id;
|
||||
private final LocalDate fromDate;
|
||||
private final LocalDate toDate;
|
||||
private final BigDecimal limit;
|
||||
|
||||
public static AgentLimitData instance(final Long id, final LocalDate fromDate, final LocalDate toDate, final BigDecimal limit) {
|
||||
return new AgentLimitData(id, fromDate, toDate, limit);
|
||||
}
|
||||
|
||||
private AgentLimitData(final Long id, final LocalDate fromDate, final LocalDate toDate, final BigDecimal limit) {
|
||||
this.id = id;
|
||||
this.fromDate = fromDate;
|
||||
this.toDate = toDate;
|
||||
this.limit = limit;
|
||||
}
|
||||
CommandProcessingResult updateDdsAccountType(Long accountTypeId, JsonCommand command);
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.service;
|
||||
|
||||
import jakarta.persistence.PersistenceException;
|
||||
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.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountType;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountTypeRepository;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.exception.DdsAccountTypeNotFoundException;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.serialization.DdsAccountTypeCommandFromApiJsonDeserializer;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.orm.jpa.JpaSystemException;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsAccountTypeWritePlatformService {
|
||||
|
||||
private final DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer;
|
||||
private final DdsAccountTypeRepository ddsAccountTypeRepository;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult createDdsAccountType(final JsonCommand command) {
|
||||
|
||||
try {
|
||||
this.fromApiJsonDeserializer.validateForCreate(command.json());
|
||||
|
||||
final DdsAccountType accountType = DdsAccountType.fromJson(command);
|
||||
this.ddsAccountTypeRepository.saveAndFlush(accountType);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(accountType.getId()) //
|
||||
.build();
|
||||
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||
handleDataIntegrityIssues(dve.getMostSpecificCause(), dve);
|
||||
return CommandProcessingResult.empty();
|
||||
} catch (final PersistenceException dve) {
|
||||
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
|
||||
handleDataIntegrityIssues(throwable, dve);
|
||||
return CommandProcessingResult.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult updateDdsAccountType(final Long accountTypeId, final JsonCommand command) {
|
||||
|
||||
try {
|
||||
this.fromApiJsonDeserializer.validateForUpdate(command.json());
|
||||
|
||||
final DdsAccountType accountType = this.ddsAccountTypeRepository.findById(accountTypeId)
|
||||
.orElseThrow(() -> new DdsAccountTypeNotFoundException(accountTypeId));
|
||||
final Map<String, Object> changesOnly = accountType.update(command);
|
||||
|
||||
if (!changesOnly.isEmpty()) {
|
||||
this.ddsAccountTypeRepository.saveAndFlush(accountType);
|
||||
}
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(accountTypeId) //
|
||||
.with(changesOnly) //
|
||||
.build();
|
||||
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||
handleDataIntegrityIssues(dve.getMostSpecificCause(), dve);
|
||||
return CommandProcessingResult.empty();
|
||||
} catch (final PersistenceException dve) {
|
||||
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
|
||||
handleDataIntegrityIssues(throwable, dve);
|
||||
return CommandProcessingResult.empty();
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
"A DDS account type with this code already exists");
|
||||
}
|
||||
|
||||
log.error("Error occured.", dve);
|
||||
throw ErrorHandler.getMappable(dve, "error.msg.dds.account.type.unknown.data.integrity.issue",
|
||||
"Unknown data integrity issue with resource: " + realCause.getMessage());
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.starter;
|
||||
|
||||
import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountTypeRepository;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.serialization.DdsAccountTypeCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeReadPlatformService;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeWritePlatformService;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeWritePlatformServiceJpaRepositoryImpl;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
@Configuration
|
||||
public class DdsAccountTypeConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(DdsAccountTypeReadPlatformService.class)
|
||||
public DdsAccountTypeReadPlatformService ddsAccountTypeReadPlatformService(JdbcTemplate jdbcTemplate) {
|
||||
return new DdsAccountTypeReadPlatformServiceImpl(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(DdsAccountTypeWritePlatformService.class)
|
||||
public DdsAccountTypeWritePlatformService ddsAccountTypeWritePlatformService(
|
||||
DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer, DdsAccountTypeRepository ddsAccountTypeRepository) {
|
||||
return new DdsAccountTypeWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, ddsAccountTypeRepository);
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -73,9 +73,13 @@ public class BatchRunsApiResource {
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Generate a DDS Batch Run", description = "Mandatory fields: jobType (INTEREST_CALCULATION, "
|
||||
+ "OD_INTEREST_CALCULATION or COMMISSION_CALCULATION), branchScope (All or Selected), jobDate, agentFrom, agentTo\n"
|
||||
+ "\n" + "Optional fields: partyFrom, partyTo (interest jobs only)")
|
||||
@Operation(summary = "Generate a DDS Batch Run", description = "Mandatory fields: jobType, branchScope (All or Selected), "
|
||||
+ "jobDate, agentFrom, agentTo\n" + "\n"
|
||||
+ "jobType is one of four Calculate/Post pairs. A calculation job works out what has been earned since the last one "
|
||||
+ "and parks it (interest in the Party's accrued-interest figure, commission in the Agent's owed figure); the "
|
||||
+ "matching posting job moves that parked figure into the customer's balance or out to the agent: "
|
||||
+ "INTEREST_CALCULATION / INTEREST_POSTING, OD_INTEREST_CALCULATION / OD_INTEREST_POSTING, "
|
||||
+ "COMMISSION_CALCULATION / COMMISSION_POSTING\n" + "\n" + "Optional fields: partyFrom, partyTo (interest jobs only)")
|
||||
public CommandProcessingResult generate(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.generateDdsBatchRun() //
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.batch.jobs.postddsinterest;
|
||||
|
||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
/** Wires the Post DDS Interest job into the platform scheduler. */
|
||||
@Configuration
|
||||
public class PostDdsInterestConfig {
|
||||
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
|
||||
@Autowired
|
||||
private BatchRunWritePlatformService batchRunWritePlatformService;
|
||||
|
||||
@Bean
|
||||
public PostDdsInterestTasklet postDdsInterestTasklet() {
|
||||
return new PostDdsInterestTasklet(batchRunWritePlatformService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
protected Step postDdsInterestStep() {
|
||||
return new StepBuilder(JobName.POST_DDS_INTEREST.name(), jobRepository)
|
||||
.tasklet(postDdsInterestTasklet(), transactionManager).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job postDdsInterestJob() {
|
||||
return new JobBuilder(JobName.POST_DDS_INTEREST.name(), jobRepository).start(postDdsInterestStep())
|
||||
.incrementer(new RunIdIncrementer()).build();
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.batch.jobs.postddsinterest;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
|
||||
/**
|
||||
* Posts DDS interest each night: the schemes whose day has come, and every account that has matured.
|
||||
*
|
||||
* The Account Type screen lets a scheme say "post interest automatically, monthly, on this day", and
|
||||
* the first pass acts on that. On the great majority of days no scheme is due and it does nothing.
|
||||
*
|
||||
* The second pass is there because that first one is not enough on its own. A scheme's posting is
|
||||
* monthly and can be switched off entirely, so a term ending between two runs leaves its last stretch
|
||||
* uncredited - and a payout made in the meantime hands the customer less than they earned. Maturity
|
||||
* is the one date that cannot wait for the next run, so accounts reaching it are brought up to date
|
||||
* here, whatever their scheme says. By morning the account is whole, and the figure the clerk reads
|
||||
* on the closure list is the figure that will be paid.
|
||||
*
|
||||
* It works off the platform's business date rather than the wall clock, so a posting always belongs to
|
||||
* the day the bank is currently working on.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class PostDdsInterestTasklet implements Tasklet {
|
||||
|
||||
private final BatchRunWritePlatformService batchRunWritePlatformService;
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) {
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
|
||||
final int schemesPosted = this.batchRunWritePlatformService.postDueAutoInterest(businessDate);
|
||||
|
||||
if (schemesPosted == 0) {
|
||||
log.debug("Post DDS Interest: no scheme is due on {}", businessDate);
|
||||
} else {
|
||||
log.info("Post DDS Interest: posted interest for {} scheme(s) as on {}", schemesPosted, businessDate);
|
||||
}
|
||||
|
||||
final int maturedPosted = this.batchRunWritePlatformService.postMaturityInterest(businessDate);
|
||||
|
||||
if (maturedPosted == 0) {
|
||||
log.debug("Post DDS Interest: no matured account is short of interest on {}", businessDate);
|
||||
} else {
|
||||
log.info("Post DDS Interest: brought {} matured account(s) up to their maturity date as on {}", maturedPosted, businessDate);
|
||||
}
|
||||
|
||||
final int promoted = this.batchRunWritePlatformService.promoteMinorsWhoHaveComeOfAge(businessDate);
|
||||
|
||||
if (promoted == 0) {
|
||||
log.debug("Post DDS Interest: no minor's account came of age with KYC verified on {}", businessDate);
|
||||
} else {
|
||||
log.info("Post DDS Interest: turned {} minor's account(s) over to their holder as on {}", promoted, businessDate);
|
||||
}
|
||||
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
}
|
||||
+19
-7
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.dds.batch.serialization;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import java.lang.reflect.Type;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
@@ -33,19 +34,26 @@ import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public final class BatchRunCommandFromApiJsonDeserializer {
|
||||
|
||||
public static final Set<String> SUPPORTED_JOB_TYPES = new HashSet<>(
|
||||
Arrays.asList("INTEREST_CALCULATION", "OD_INTEREST_CALCULATION", "COMMISSION_CALCULATION"));
|
||||
// The four Calculate/Post pairs from the legacy Interest Part menu. A calculation job works out
|
||||
// what has been earned and parks it; the matching posting job moves it where it belongs.
|
||||
//
|
||||
// Lists, not Sets, so isOneOfTheseStringValues resolves to the overload that lowercases both
|
||||
// sides - the varargs one lowercases only the submitted value and so never matches these.
|
||||
public static final List<String> SUPPORTED_JOB_TYPES = List.of("INTEREST_CALCULATION", "INTEREST_POSTING",
|
||||
"OD_INTEREST_CALCULATION", "OD_INTEREST_POSTING", "COMMISSION_CALCULATION", "COMMISSION_POSTING",
|
||||
"MATURITY_AUTO_CLOSE");
|
||||
|
||||
private static final Set<String> SUPPORTED_BRANCH_SCOPES = new HashSet<>(Arrays.asList("All", "Selected"));
|
||||
private static final List<String> SUPPORTED_BRANCH_SCOPES = List.of("All", "Selected");
|
||||
|
||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("jobType", "branchScope", "officeNames",
|
||||
"jobDate", "agentFrom", "agentTo", "partyFrom", "partyTo", "dateFormat", "locale"));
|
||||
"jobDate", "agentFrom", "agentTo", "partyFrom", "partyTo", "subType", "dateFormat", "locale"));
|
||||
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
|
||||
@@ -70,16 +78,20 @@ public final class BatchRunCommandFromApiJsonDeserializer {
|
||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
||||
|
||||
v.reset().parameter("jobType").value(this.fromApiJsonHelper.extractStringNamed("jobType", element)).notBlank()
|
||||
.isOneOfTheseStringValues(SUPPORTED_JOB_TYPES.toArray());
|
||||
.isOneOfTheseStringValues(SUPPORTED_JOB_TYPES);
|
||||
final String branchScope = this.fromApiJsonHelper.extractStringNamed("branchScope", element);
|
||||
v.reset().parameter("branchScope").value(branchScope).notBlank().isOneOfTheseStringValues(SUPPORTED_BRANCH_SCOPES.toArray());
|
||||
v.reset().parameter("branchScope").value(branchScope).notBlank().isOneOfTheseStringValues(SUPPORTED_BRANCH_SCOPES);
|
||||
|
||||
if ("Selected".equals(branchScope)) {
|
||||
v.reset().parameter("officeNames").value(this.fromApiJsonHelper.extractJsonArrayNamed("officeNames", element))
|
||||
.jsonArrayNotEmpty();
|
||||
}
|
||||
|
||||
v.reset().parameter("jobDate").value(this.fromApiJsonHelper.extractLocalDateNamed("jobDate", element)).notNull();
|
||||
// Not in the future. A run works out what has been earned up to its job date, so a date that
|
||||
// has not arrived pays interest for days nobody has held their money yet - and worse, the run
|
||||
// stamps itself as calculated through that date, so the real days are skipped when they come.
|
||||
final LocalDate jobDate = this.fromApiJsonHelper.extractLocalDateNamed("jobDate", element);
|
||||
v.reset().parameter("jobDate").value(jobDate).notNull().validateDateBeforeOrEqual(DateUtils.getBusinessLocalDate());
|
||||
v.reset().parameter("agentFrom").value(this.fromApiJsonHelper.extractLongNamed("agentFrom", element)).notNull()
|
||||
.integerGreaterThanZero();
|
||||
v.reset().parameter("agentTo").value(this.fromApiJsonHelper.extractLongNamed("agentTo", element)).notNull()
|
||||
|
||||
+18
-3
@@ -45,9 +45,10 @@ public class BatchRunReadPlatformServiceImpl implements BatchRunReadPlatformServ
|
||||
private static final class BatchRunMapper implements RowMapper<BatchRunData> {
|
||||
|
||||
public String schema() {
|
||||
return " b.id as id, b.job_type as jobType, b.branch_scope as branchScope, b.job_date as jobDate, "
|
||||
return " b.id as id, b.job_type as jobType, b.branch_scope as branchScope, b.sub_type as subType, b.job_date as jobDate, "
|
||||
+ " b.agent_from as agentFrom, b.agent_to as agentTo, b.party_from as partyFrom, "
|
||||
+ " b.party_to as partyTo, b.status as status, b.total_rows as totalRows, b.total_amount as totalAmount, "
|
||||
+ " b.party_to as partyTo, b.status as status, b.total_rows as totalRows, "
|
||||
+ " b.rows_posted as rowsPosted, b.failure_reason as failureReason, b.total_amount as totalAmount, "
|
||||
+ " b.generated_on_date as generatedOnDate, b.posted_on_date as postedOnDate from m_dds_batch_run b";
|
||||
}
|
||||
|
||||
@@ -57,6 +58,7 @@ public class BatchRunReadPlatformServiceImpl implements BatchRunReadPlatformServ
|
||||
.id(rs.getLong("id")) //
|
||||
.jobType(rs.getString("jobType")) //
|
||||
.branchScope(rs.getString("branchScope")) //
|
||||
.subType(rs.getString("subType")) //
|
||||
.jobDate(JdbcSupport.getLocalDate(rs, "jobDate")) //
|
||||
.agentFrom(rs.getLong("agentFrom")) //
|
||||
.agentTo(rs.getLong("agentTo")) //
|
||||
@@ -64,6 +66,8 @@ public class BatchRunReadPlatformServiceImpl implements BatchRunReadPlatformServ
|
||||
.partyTo(JdbcSupport.getLong(rs, "partyTo")) //
|
||||
.status(rs.getString("status")) //
|
||||
.totalRows(rs.getInt("totalRows")) //
|
||||
.rowsPosted(rs.getInt("rowsPosted")) //
|
||||
.failureReason(rs.getString("failureReason")) //
|
||||
.totalAmount(rs.getBigDecimal("totalAmount")) //
|
||||
.generatedOnDate(JdbcSupport.getLocalDate(rs, "generatedOnDate")) //
|
||||
.postedOnDate(JdbcSupport.getLocalDate(rs, "postedOnDate")) //
|
||||
@@ -77,7 +81,10 @@ public class BatchRunReadPlatformServiceImpl implements BatchRunReadPlatformServ
|
||||
public String schema() {
|
||||
return " r.batch_run_id as batchRunId, r.id as id, r.party_id as partyId, p.party_code as partyCode, "
|
||||
+ " p.party_name as partyName, r.agent_id as agentId, a.agent_code as agentCode, "
|
||||
+ " r.amount as amount from m_dds_batch_run_row r " + " left join m_dds_party p on p.id = r.party_id "
|
||||
+ " a.agent_name as agentName, r.amount as amount, r.base_amount as baseAmount, "
|
||||
+ " r.rate_percent as ratePercent, r.gross_amount as grossAmount, r.tds_amount as tdsAmount, "
|
||||
+ " r.deduction_amount as deductionAmount, r.from_date as fromDate, r.to_date as toDate "
|
||||
+ " from m_dds_batch_run_row r " + " left join m_dds_party p on p.id = r.party_id "
|
||||
+ " join m_dds_agent a on a.id = r.agent_id";
|
||||
}
|
||||
|
||||
@@ -91,7 +98,15 @@ public class BatchRunReadPlatformServiceImpl implements BatchRunReadPlatformServ
|
||||
.partyName(rs.getString("partyName")) //
|
||||
.agentId(rs.getLong("agentId")) //
|
||||
.agentCode(rs.getString("agentCode")) //
|
||||
.agentName(rs.getString("agentName")) //
|
||||
.amount(rs.getBigDecimal("amount")) //
|
||||
.baseAmount(rs.getBigDecimal("baseAmount")) //
|
||||
.ratePercent(rs.getBigDecimal("ratePercent")) //
|
||||
.grossAmount(rs.getBigDecimal("grossAmount")) //
|
||||
.tdsAmount(rs.getBigDecimal("tdsAmount")) //
|
||||
.deductionAmount(rs.getBigDecimal("deductionAmount")) //
|
||||
.fromDate(JdbcSupport.getLocalDate(rs, "fromDate")) //
|
||||
.toDate(JdbcSupport.getLocalDate(rs, "toDate")) //
|
||||
.build();
|
||||
return Map.entry(batchRunId, data);
|
||||
}
|
||||
|
||||
+52
@@ -18,12 +18,64 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.batch.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||
|
||||
public interface BatchRunWritePlatformService {
|
||||
|
||||
CommandProcessingResult generateBatchRun(JsonCommand command);
|
||||
|
||||
CommandProcessingResult postBatchRun(Long batchRunId, JsonCommand command);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @return how many schemes were posted for - zero on the great majority of days, when nothing is
|
||||
* due.
|
||||
*/
|
||||
int postDueAutoInterest(LocalDate businessDate);
|
||||
|
||||
/**
|
||||
* Brings every matured account's interest up to its own maturity date.
|
||||
*
|
||||
* Run nightly beside the scheme postings. Interest otherwise arrives only when a posting run
|
||||
* happens - monthly, per scheme, and switchable off - so a term ending between two runs leaves
|
||||
* its last stretch uncredited, and a payout in the meantime pays the customer short.
|
||||
*
|
||||
* @return how many accounts were credited; zero on most nights.
|
||||
*/
|
||||
int postMaturityInterest(LocalDate businessDate);
|
||||
|
||||
/**
|
||||
* Works out one account's interest up to a date and credits it, returning what was paid.
|
||||
*
|
||||
* Called by the nightly pass above, and again by the maturity payout as a second line of defence
|
||||
* in case that pass never ran. Returns zero, having written nothing, when interest is already
|
||||
* current.
|
||||
*/
|
||||
BigDecimal postInterestThrough(Party party, LocalDate through);
|
||||
|
||||
/**
|
||||
* Turns over the minors' accounts whose holders have come of age and had their own KYC verified.
|
||||
*
|
||||
* Run nightly. The birthday alone does not do it: an eighteen-year-old the bank has not yet
|
||||
* identified is somebody it cannot pay, so the turnover waits on their KYC - and until then the
|
||||
* payout refuses while collections carry on as before.
|
||||
*
|
||||
* @return how many accounts were turned over; zero on almost every night.
|
||||
*/
|
||||
int promoteMinorsWhoHaveComeOfAge(LocalDate businessDate);
|
||||
|
||||
/**
|
||||
* Credits every row on a run and marks it posted. Called from the background thread that
|
||||
* {@code postBatchRun} hands the work to, not from a request.
|
||||
*/
|
||||
void applyAndMarkPosted(Long batchRunId);
|
||||
|
||||
/** Records on the run why it stopped, when it did, so a screen can say so rather than only a log. */
|
||||
void markPostingFailed(Long batchRunId, String reason);
|
||||
}
|
||||
|
||||
+793
-66
@@ -20,39 +20,80 @@ package org.apache.fineract.portfolio.dds.batch.service;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
||||
import org.apache.fineract.organisation.agent.domain.AgentRepository;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentCommissionAccountUnusableException;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
|
||||
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlab;
|
||||
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
|
||||
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
|
||||
import org.apache.fineract.portfolio.dds.calculation.DdsCommissionCalculation;
|
||||
import org.apache.fineract.portfolio.dds.calculation.DdsInterestCalculation;
|
||||
import org.apache.fineract.portfolio.dds.batch.domain.BatchRun;
|
||||
import org.apache.fineract.portfolio.dds.batch.domain.BatchRunRepository;
|
||||
import org.apache.fineract.portfolio.dds.batch.domain.BatchRunRow;
|
||||
import org.apache.fineract.portfolio.dds.batch.exception.BatchRunAlreadyPostedException;
|
||||
import org.apache.fineract.portfolio.dds.batch.exception.BatchRunNotFoundException;
|
||||
import org.apache.fineract.portfolio.dds.batch.exception.NoCommissionSlabException;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountType;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountTypeRepository;
|
||||
import org.apache.fineract.portfolio.dds.batch.serialization.BatchRunCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
|
||||
import org.apache.fineract.portfolio.dds.party.exception.PartyNotFoundException;
|
||||
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
|
||||
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransaction;
|
||||
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* The DDS Interest Part: the four Calculate/Post job pairs from the legacy menu.
|
||||
*
|
||||
* A <em>calculation</em> job works out what has been earned since the last one and parks it - interest
|
||||
* in the Party's "Int.Calc" figure, commission in the Agent's "Comm" figure. Neither is spendable yet.
|
||||
* A <em>posting</em> job then moves that parked figure where it belongs: into the customer's balance,
|
||||
* or out to the agent. This is why the legacy menu has eight entries rather than four.
|
||||
*
|
||||
* Every job is itself a two-step Generate/Post: Generate saves the rows it worked out and changes
|
||||
* nothing else, Post applies exactly those rows. A figure that looks wrong is thrown away by simply
|
||||
* not posting it.
|
||||
*
|
||||
* Nothing here can be applied twice. A calculation stamps how far it has calculated through, so
|
||||
* 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.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWritePlatformService {
|
||||
|
||||
private static final BigDecimal DAYS_IN_YEAR = BigDecimal.valueOf(365);
|
||||
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
|
||||
private static final String INTEREST_CALCULATION = "INTEREST_CALCULATION";
|
||||
private static final String INTEREST_POSTING = "INTEREST_POSTING";
|
||||
private static final String OD_INTEREST_CALCULATION = "OD_INTEREST_CALCULATION";
|
||||
private static final String OD_INTEREST_POSTING = "OD_INTEREST_POSTING";
|
||||
private static final String COMMISSION_CALCULATION = "COMMISSION_CALCULATION";
|
||||
private static final String COMMISSION_POSTING = "COMMISSION_POSTING";
|
||||
private static final String MATURITY_AUTO_CLOSE = "MATURITY_AUTO_CLOSE";
|
||||
|
||||
private static final String ACCOUNT_SLAB = "ACCOUNT";
|
||||
|
||||
private static final String DIRECTION_CREDIT = "CREDIT";
|
||||
private static final String DIRECTION_DEBIT = "DEBIT";
|
||||
|
||||
private final BatchRunCommandFromApiJsonDeserializer fromApiJsonDeserializer;
|
||||
private final BatchRunRepository batchRunRepository;
|
||||
@@ -60,6 +101,40 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
private final AgentRepository agentRepository;
|
||||
private final PartyTransactionRepository partyTransactionRepository;
|
||||
private final CommissionSlabRepository commissionSlabRepository;
|
||||
private final DdsAccountTypeRepository ddsAccountTypeRepository;
|
||||
|
||||
/** Puts a commission payment onto the bank's own books as well as DDS's - see DdsLedger. */
|
||||
private final DdsLedger ddsLedger;
|
||||
/** Runs the crediting away from the request that asked for it - see postBatchRun. */
|
||||
private final DdsBatchPostingRunner postingRunner;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* Settles a matured account, for a Maturity Auto Close run.
|
||||
*
|
||||
* Held as a provider rather than the service itself because the two point at each other - paying
|
||||
* a maturity brings its interest up to date through this class, and this class hands the settling
|
||||
* back to that one. Asked for at the moment it is used, the pair wire up; asked for at
|
||||
* construction, neither can be built. Reused rather than reimplemented here so an automatic
|
||||
* settlement and a clerk's go through the same code and cannot drift apart.
|
||||
*/
|
||||
private final ObjectProvider<PartyWritePlatformService> partyWritePlatformService;
|
||||
|
||||
/**
|
||||
* Everything needed to say whether an agent's commission can be paid into an account, and if not,
|
||||
* why. Read in one go so each reason can be named rather than the deposit simply being attempted.
|
||||
*/
|
||||
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";
|
||||
|
||||
private static final int SAVINGS_DEPOSIT_TYPE = 100;
|
||||
private static final int FIXED_DEPOSIT_TYPE = 200;
|
||||
private static final int ACTIVE_SAVINGS_STATUS = 300;
|
||||
private static final int DORMANT_SAVINGS_STATUS = 303;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -77,14 +152,26 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
|
||||
final Set<String> officeNames = "Selected".equals(branchScope) ? extractOfficeNames(command) : null;
|
||||
|
||||
final BatchRun batchRun = new BatchRun(jobType, branchScope, jobDate, agentFrom, agentTo, partyFrom, partyTo, LocalDate.now());
|
||||
// Absent means every scheme, which is what a run started by hand from the screen does. The
|
||||
// scheduled job names one, because a posting day belongs to an account type rather than the bank.
|
||||
final String subType = command.parameterExists("subType") ? command.stringValueOfParameterNamed("subType") : null;
|
||||
|
||||
final BatchRun batchRun = new BatchRun(jobType, branchScope, jobDate, agentFrom, agentTo, partyFrom, partyTo, LocalDate.now(),
|
||||
subType);
|
||||
|
||||
switch (jobType) {
|
||||
case "INTEREST_CALCULATION" -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, false,
|
||||
officeNames);
|
||||
case "OD_INTEREST_CALCULATION" -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, true,
|
||||
officeNames);
|
||||
case "COMMISSION_CALCULATION" -> generateCommissionRows(batchRun, agentFrom, agentTo, jobDate, officeNames);
|
||||
case INTEREST_CALCULATION -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, false,
|
||||
officeNames, subType);
|
||||
case OD_INTEREST_CALCULATION -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, true,
|
||||
officeNames, subType);
|
||||
case INTEREST_POSTING -> generateInterestPostingRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, false,
|
||||
officeNames, subType);
|
||||
case OD_INTEREST_POSTING -> generateInterestPostingRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, true,
|
||||
officeNames, subType);
|
||||
case COMMISSION_CALCULATION -> generateCommissionRows(batchRun, agentFrom, agentTo, jobDate, officeNames);
|
||||
case COMMISSION_POSTING -> generateCommissionPostingRows(batchRun, agentFrom, agentTo, jobDate, officeNames);
|
||||
case MATURITY_AUTO_CLOSE -> generateMaturityAutoCloseRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate,
|
||||
officeNames, subType);
|
||||
default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType);
|
||||
}
|
||||
|
||||
@@ -96,9 +183,11 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
.build();
|
||||
}
|
||||
|
||||
/** 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 same free-text branch concept the legacy screen itself used. */
|
||||
/**
|
||||
* 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
|
||||
* same free-text branch concept the legacy screen itself used.
|
||||
*/
|
||||
private Set<String> extractOfficeNames(final JsonCommand command) {
|
||||
if (!command.parameterExists("officeNames")) {
|
||||
return null;
|
||||
@@ -118,47 +207,211 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
return homeBranch != null && officeNames.contains(homeBranch.trim().toUpperCase());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Interest calculation
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Works out each account's interest since it was last calculated, up to the job date.
|
||||
*
|
||||
* A daily-deposit balance is built up a little at a time, so the closing balance says nothing
|
||||
* about how long the money was actually held: an instalment paid on the last day of the month has
|
||||
* earned a day, not a month. Each account's interest is therefore worked out from its daily
|
||||
* product - its balance summed over every day it stood at that balance - which is the same basis
|
||||
* the maturity figure quoted at account opening uses, so the two agree.
|
||||
*/
|
||||
private void generateInterestRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo, final Long partyFrom,
|
||||
final Long partyTo, final LocalDate jobDate, final boolean odOnly, final Set<String> officeNames) {
|
||||
final List<Party> parties = odOnly
|
||||
? this.partyRepository.findForOdInterestBatch(agentFrom, agentTo, partyFrom, partyTo)
|
||||
: this.partyRepository.findForInterestBatch(agentFrom, agentTo, partyFrom, partyTo);
|
||||
final Long partyTo, final LocalDate jobDate, final boolean odInterest, final Set<String> officeNames,
|
||||
final String subType) {
|
||||
|
||||
final List<Party> parties = odInterest
|
||||
? this.partyRepository.findForOdInterestBatch(agentFrom, agentTo, partyFrom, partyTo, subType)
|
||||
: this.partyRepository.findForInterestBatch(agentFrom, agentTo, partyFrom, partyTo, subType);
|
||||
|
||||
final List<Party> eligible = new ArrayList<>(parties.size());
|
||||
for (final Party party : parties) {
|
||||
if (matchesSelectedBranch(party.getAgent().getHomeBranch(), officeNames)) {
|
||||
eligible.add(party);
|
||||
}
|
||||
}
|
||||
if (eligible.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Map<Long, List<PartyTransaction>> movements = movementsByParty(eligible, jobDate);
|
||||
|
||||
for (final Party party : eligible) {
|
||||
final BigDecimal rate = party.getInterestRate();
|
||||
if (rate == null || rate.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Where this run picks up: the last run's through-date, or the day the account opened if
|
||||
// it has never been calculated.
|
||||
final LocalDate accrueFrom = party.interestCalculatedThrough(odInterest) != null
|
||||
? party.interestCalculatedThrough(odInterest)
|
||||
: party.getOpeningDate();
|
||||
if (!accrueFrom.isBefore(jobDate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final BigDecimal dailyProduct = DdsInterestCalculation.dailyProduct(party.getOpeningBalance(),
|
||||
movementsOf(movements.getOrDefault(party.getId(), List.of())), accrueFrom, jobDate);
|
||||
if (dailyProduct.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final BigDecimal interest = DdsInterestCalculation.interestOn(dailyProduct, rate);
|
||||
if (interest.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
batchRun.addRow(
|
||||
BatchRunRow.forInterest(party.getId(), party.getAgent().getId(), interest, dailyProduct, rate, accrueFrom, jobDate));
|
||||
}
|
||||
}
|
||||
|
||||
private Map<Long, List<PartyTransaction>> movementsByParty(final List<Party> parties, final LocalDate upTo) {
|
||||
final List<Long> partyIds = new ArrayList<>(parties.size());
|
||||
for (final Party party : parties) {
|
||||
partyIds.add(party.getId());
|
||||
}
|
||||
|
||||
// One query for the whole run rather than one per account, then grouped here. The repository
|
||||
// orders by party and date, so each list is already in the order the replay below needs.
|
||||
final List<PartyTransaction> transactions = this.partyTransactionRepository.findForPartiesUpTo(partyIds, upTo);
|
||||
|
||||
final Map<Long, List<PartyTransaction>> byParty = new HashMap<>();
|
||||
for (final PartyTransaction transaction : transactions) {
|
||||
byParty.computeIfAbsent(transaction.getParty().getId(), k -> new ArrayList<>()).add(transaction);
|
||||
}
|
||||
return byParty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns this account's transactions into the plain date/amount pairs the sum needs, with a payout
|
||||
* carried as a negative. The calculation itself lives in DdsInterestCalculation, apart from the
|
||||
* job that runs it, so it can be checked on its own.
|
||||
*/
|
||||
private List<DdsInterestCalculation.Movement> movementsOf(final List<PartyTransaction> transactions) {
|
||||
final List<DdsInterestCalculation.Movement> movements = new ArrayList<>(transactions.size());
|
||||
for (final PartyTransaction transaction : transactions) {
|
||||
final BigDecimal signed = DIRECTION_DEBIT.equals(transaction.getDirection()) ? transaction.getAmount().negate()
|
||||
: transaction.getAmount();
|
||||
movements.add(new DdsInterestCalculation.Movement(transaction.getTransactionDate(), signed));
|
||||
}
|
||||
return movements;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Interest posting
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Lists the interest each account is holding, ready to be credited to its balance. */
|
||||
private void generateInterestPostingRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo, final Long partyFrom,
|
||||
final Long partyTo, final LocalDate jobDate, final boolean odInterest, final Set<String> officeNames,
|
||||
final String subType) {
|
||||
|
||||
final List<Party> parties = odInterest
|
||||
? this.partyRepository.findWithAccruedOdInterest(agentFrom, agentTo, partyFrom, partyTo, subType)
|
||||
: this.partyRepository.findWithAccruedInterest(agentFrom, agentTo, partyFrom, partyTo, subType);
|
||||
|
||||
for (final Party party : parties) {
|
||||
if (!matchesSelectedBranch(party.getAgent().getHomeBranch(), officeNames)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final BigDecimal rate = party.getInterestRate();
|
||||
final BigDecimal balance = party.getCurrentBalance();
|
||||
if (rate == null || rate.compareTo(BigDecimal.ZERO) <= 0 || balance.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final LocalDate accrueFrom = party.getInterestDate() != null ? party.getInterestDate() : party.getOpeningDate();
|
||||
final long days = ChronoUnit.DAYS.between(accrueFrom, jobDate);
|
||||
if (days <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final BigDecimal amount = balance.multiply(rate).multiply(BigDecimal.valueOf(days))
|
||||
.divide(HUNDRED.multiply(DAYS_IN_YEAR), 6, RoundingMode.HALF_UP).setScale(2, RoundingMode.HALF_UP);
|
||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
batchRun.addRow(new BatchRunRow(party.getId(), party.getAgent().getId(), amount));
|
||||
batchRun.addRow(
|
||||
BatchRunRow.forPosting(party.getId(), party.getAgent().getId(), party.accruedInterest(odInterest), jobDate));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Maturity auto close
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Lists matured accounts that can be settled and shut without anybody choosing anything today.
|
||||
*
|
||||
* Two things have to agree before an account is listed. The scheme it is on must have maturity
|
||||
* auto-closing switched on - that is the bank's decision, made once per scheme. And the account
|
||||
* itself must have named where its proceeds go - that is the customer's, made when they opened
|
||||
* it. Neither stands in for the other: a scheme with the switch on cannot send a customer's money
|
||||
* somewhere they never nominated, and a nominated account means nothing on a scheme the bank has
|
||||
* not turned this on for.
|
||||
*
|
||||
* Everything else about the account - that it has matured, still holds money, is not under lien,
|
||||
* has its KYC in order - is decided by the settling itself, which applies the same rules to a
|
||||
* clerk's payout. Repeating them here would be two lists of rules to keep in step.
|
||||
*/
|
||||
private void generateMaturityAutoCloseRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo, final Long partyFrom,
|
||||
final Long partyTo, final LocalDate jobDate, final Set<String> officeNames, final String subType) {
|
||||
|
||||
final Set<String> autoClosingSchemes = schemesWithMaturityAutoClose();
|
||||
if (autoClosingSchemes.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final List<Party> due = this.partyRepository.findDueForMaturityAutoClose(agentFrom, agentTo, partyFrom, partyTo, subType, jobDate);
|
||||
|
||||
for (final Party party : due) {
|
||||
if (!matchesSelectedBranch(party.getAgent().getHomeBranch(), officeNames)) {
|
||||
continue;
|
||||
}
|
||||
if (!autoClosingSchemes.contains(normalisedSubType(party.getSubType()))) {
|
||||
continue;
|
||||
}
|
||||
// The balance as it stands. Interest still to be brought up to the maturity date is added
|
||||
// when the row is applied, so what is listed is the floor of what the customer receives,
|
||||
// never more than it.
|
||||
batchRun.addRow(BatchRunRow.forPosting(party.getId(), party.getAgent().getId(), party.getCurrentBalance(), jobDate));
|
||||
}
|
||||
}
|
||||
|
||||
/** The account-type codes the bank has switched maturity auto-closing on for. */
|
||||
private Set<String> schemesWithMaturityAutoClose() {
|
||||
final Set<String> codes = new HashSet<>();
|
||||
for (final DdsAccountType accountType : this.ddsAccountTypeRepository.findAll()) {
|
||||
if (accountType.isMaturityAutoClose() && accountType.isActive()) {
|
||||
codes.add(normalisedSubType(accountType.getAccountTypeCode()));
|
||||
}
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* An account names its scheme by the code as it was typed, and the two spellings have to meet.
|
||||
* Compared without case or surrounding space for the same reason the payment modes are.
|
||||
*/
|
||||
private String normalisedSubType(final String subType) {
|
||||
return subType == null ? "" : subType.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Commission calculation
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Works out each agent's commission on what they collected since their last commission run.
|
||||
*
|
||||
* The chart gives a percentage on the collection volume; TDS and the agent's standing deductions
|
||||
* come off that, leaving what the agent is actually owed. Both the gross figure and each
|
||||
* deduction are recorded on the row, because that breakdown is what the Agent Commission
|
||||
* Statement prints and what an agent querying their payout needs to see.
|
||||
*/
|
||||
private void generateCommissionRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo, final LocalDate jobDate,
|
||||
final Set<String> officeNames) {
|
||||
|
||||
final List<Agent> agents = this.agentRepository.findBetweenIds(agentFrom, agentTo);
|
||||
|
||||
for (final Agent agent : agents) {
|
||||
if (!matchesSelectedBranch(agent.getHomeBranch(), officeNames)) {
|
||||
continue;
|
||||
}
|
||||
// An agent the bank has switched commission off for, or one no longer collecting, earns
|
||||
// nothing new - but what they have already earned stays owed to them and is still payable.
|
||||
if (!agent.isCommissionCalculation() || !agent.isActive()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final LocalDate fromDate = agent.getLastCommissionDate() != null ? agent.getLastCommissionDate().plusDays(1)
|
||||
: agent.getAgentOpeningDate();
|
||||
@@ -171,57 +424,531 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
continue;
|
||||
}
|
||||
|
||||
final List<CommissionSlab> matchingSlabs = this.commissionSlabRepository.findMatchingSlabs("ACCOUNT", collectionVolume);
|
||||
// A volume that falls in a gap in the chart is a fault in the chart, not a reason to pay
|
||||
// this agent nothing: an agent who worked all month would read exactly like one who
|
||||
// collected nothing, and nobody would notice until they complained.
|
||||
final List<CommissionSlab> matchingSlabs = this.commissionSlabRepository.findMatchingSlabs(ACCOUNT_SLAB, collectionVolume);
|
||||
if (matchingSlabs.isEmpty()) {
|
||||
throw new NoCommissionSlabException(agent.getAgentCode(), collectionVolume);
|
||||
}
|
||||
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(collectionVolume,
|
||||
matchingSlabs.get(0).getCommissionPercent(), agent.isTdsApplicable(), agent.getTdsPercent(),
|
||||
agent.totalDeductions());
|
||||
if (!commission.isPayable()) {
|
||||
// The deductions have swallowed this period's commission. Nothing is owed, and the
|
||||
// agent is not put into debt over it - the deductions are standing figures the bank
|
||||
// adjusts, not a balance this job is entitled to carry forward.
|
||||
continue;
|
||||
}
|
||||
|
||||
final BigDecimal commissionPercent = matchingSlabs.get(0).getCommissionPercent();
|
||||
final BigDecimal amount = collectionVolume.multiply(commissionPercent).divide(HUNDRED, 2, RoundingMode.HALF_UP);
|
||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
batchRun.addRow(new BatchRunRow(null, agent.getId(), amount));
|
||||
batchRun.addRow(BatchRunRow.forCommission(agent.getId(), commission.net(), commission.collectionVolume(),
|
||||
commission.ratePercent(), commission.gross(), commission.tds(), commission.deductions(), fromDate, jobDate));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Commission posting
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Lists what each agent is owed, ready to be paid out. */
|
||||
private void generateCommissionPostingRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo,
|
||||
final LocalDate jobDate, final Set<String> officeNames) {
|
||||
|
||||
final List<Agent> agents = this.agentRepository.findWithCommissionOwed(agentFrom, agentTo);
|
||||
|
||||
for (final Agent agent : agents) {
|
||||
if (!matchesSelectedBranch(agent.getHomeBranch(), officeNames)) {
|
||||
continue;
|
||||
}
|
||||
batchRun.addRow(BatchRunRow.forPosting(null, agent.getId(), agent.getCommission(), jobDate));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Post
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult postBatchRun(final Long batchRunId, final JsonCommand command) {
|
||||
|
||||
final BatchRun batchRun = this.batchRunRepository.findById(batchRunId).orElseThrow(() -> new BatchRunNotFoundException(batchRunId));
|
||||
if (batchRun.isPosted()) {
|
||||
if (batchRun.isPosted() || batchRun.isPosting()) {
|
||||
throw new BatchRunAlreadyPostedException(batchRunId);
|
||||
}
|
||||
|
||||
final boolean isCommissionJob = "COMMISSION_CALCULATION".equals(batchRun.getJobType());
|
||||
final String transactionType = "OD_INTEREST_CALCULATION".equals(batchRun.getJobType()) ? "OD_INTEREST_POST" : "INTEREST_POST";
|
||||
|
||||
for (final BatchRunRow row : batchRun.getRows()) {
|
||||
if (isCommissionJob) {
|
||||
final Agent agent = this.agentRepository.findById(row.getAgentId())
|
||||
.orElseThrow(() -> new AgentNotFoundException(row.getAgentId()));
|
||||
agent.postCommission(row.getAmount(), batchRun.getJobDate());
|
||||
} else {
|
||||
final Party party = this.partyRepository.findById(row.getPartyId())
|
||||
.orElseThrow(() -> new PartyNotFoundException(row.getPartyId()));
|
||||
final Agent agent = party.getAgent();
|
||||
final BigDecimal newBalance = party.credit(row.getAmount());
|
||||
party.markInterestCalculatedThrough(batchRun.getJobDate());
|
||||
|
||||
final PartyTransaction transaction = new PartyTransaction(party, agent, batchRun.getJobDate(), transactionType,
|
||||
"CREDIT", null, null, null, row.getAmount(), null, null, "DDS batch run #" + batchRun.getId(), newBalance);
|
||||
this.partyTransactionRepository.save(transaction);
|
||||
}
|
||||
// Only the crediting runs is worth taking off the request. A calculation run writes a figure
|
||||
// against each account and finishes in under a second on four hundred of them; sending it away
|
||||
// would only mean the posting run that follows could be generated before it had finished, and
|
||||
// find nothing accrued to pay.
|
||||
if (!isCrediting(batchRun.getJobType())) {
|
||||
applyRows(batchRun);
|
||||
batchRun.markPosted(LocalDate.now());
|
||||
this.batchRunRepository.saveAndFlush(batchRun);
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(batchRunId).build();
|
||||
}
|
||||
|
||||
batchRun.markPosted(LocalDate.now());
|
||||
// Marked under way and committed before the work starts, so a second press finds it already
|
||||
// running rather than crediting every account twice.
|
||||
batchRun.markPosting();
|
||||
this.batchRunRepository.saveAndFlush(batchRun);
|
||||
|
||||
// The crediting itself runs away from this request. Each account costs what a savings deposit
|
||||
// costs, and a branch of any size would leave the clerk watching a still screen with no way to
|
||||
// tell a slow run from a hung one. The context a background thread does not inherit is handed
|
||||
// over with it: the tenant, or there is no database; the business date, or the postings are
|
||||
// dated by the wall clock; the signed-in user, or every row fails its audit column.
|
||||
this.postingRunner.post(batchRunId, ThreadLocalContextUtil.getTenant(), ThreadLocalContextUtil.getBusinessDates(),
|
||||
ThreadLocalContextUtil.getDataSourceContext(), SecurityContextHolder.getContext());
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(batchRunId) //
|
||||
.build();
|
||||
}
|
||||
|
||||
/** The runs that move money into accounts, and so carry the cost of a savings deposit on each one. */
|
||||
private boolean isCrediting(final String jobType) {
|
||||
return INTEREST_POSTING.equals(jobType) || OD_INTEREST_POSTING.equals(jobType) || COMMISSION_POSTING.equals(jobType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits every row on the run and marks it posted. Called from the background thread.
|
||||
*
|
||||
* One transaction for the whole run: either every account on it is credited or none is. A part-
|
||||
* credited run would leave the branch to work out by hand which customers had been paid and which
|
||||
* had not, and that is a worse position to be in than simply running it again.
|
||||
*/
|
||||
@Transactional
|
||||
@Override
|
||||
public void applyAndMarkPosted(final Long batchRunId) {
|
||||
final BatchRun batchRun = this.batchRunRepository.findById(batchRunId).orElseThrow(() -> new BatchRunNotFoundException(batchRunId));
|
||||
applyRows(batchRun);
|
||||
batchRun.markPosted(LocalDate.now());
|
||||
this.batchRunRepository.saveAndFlush(batchRun);
|
||||
}
|
||||
|
||||
/** Writes onto the run why it stopped, in a transaction of its own - the failed one has rolled back. */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
@Override
|
||||
public void markPostingFailed(final Long batchRunId, final String reason) {
|
||||
this.batchRunRepository.findById(batchRunId).ifPresent(run -> {
|
||||
run.markFailed(reason);
|
||||
this.batchRunRepository.saveAndFlush(run);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies exactly the rows a Generate call worked out. Shared by the screen's Post button and by
|
||||
* the scheduled job, so an automatic posting goes through the same code as a manual one rather
|
||||
* than a second implementation that could drift from it.
|
||||
*/
|
||||
private void applyRows(final BatchRun batchRun) {
|
||||
final String jobType = batchRun.getJobType();
|
||||
final LocalDate jobDate = batchRun.getJobDate();
|
||||
|
||||
int done = 0;
|
||||
for (final BatchRunRow row : batchRun.getRows()) {
|
||||
switch (jobType) {
|
||||
case INTEREST_CALCULATION -> accrue(row, jobDate, false);
|
||||
case OD_INTEREST_CALCULATION -> accrue(row, jobDate, true);
|
||||
case INTEREST_POSTING -> postInterest(row, batchRun, false);
|
||||
case OD_INTEREST_POSTING -> postInterest(row, batchRun, true);
|
||||
case COMMISSION_CALCULATION -> accrueCommission(row, jobDate);
|
||||
case COMMISSION_POSTING -> payCommission(row, batchRun);
|
||||
case MATURITY_AUTO_CLOSE -> closeAtMaturity(row);
|
||||
default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles and shuts one matured account, through the same code a clerk's payout goes through.
|
||||
*
|
||||
* The row carries no destination and none is taken from it: the account is read again now and its
|
||||
* own nominated account used. A run generated last week and posted today would otherwise pay into
|
||||
* whatever was nominated last week, which is exactly the instruction the customer may since have
|
||||
* changed.
|
||||
*
|
||||
* Nothing is caught here. If one account cannot be settled - it was closed in the meantime, a
|
||||
* lien was placed on it, its nominated account was shut - the whole run rolls back and the clerk
|
||||
* is told why, which is how every other posting run behaves. A run that quietly settled nine of
|
||||
* ten accounts would leave somebody to work out which one it was.
|
||||
*/
|
||||
private void closeAtMaturity(final BatchRunRow row) {
|
||||
this.partyWritePlatformService.getObject().closeAtMaturity(row.getPartyId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parks the calculated interest against the account and records how far it is calculated through.
|
||||
* No ledger entry: nothing has moved yet, and the customer's balance is untouched.
|
||||
*
|
||||
* The through-date is checked again here, not just when the row was worked out. Generating a run
|
||||
* does not stamp anything, so two runs can be generated over the same period and each would look
|
||||
* valid on its own - posting both would earn the same interest twice. Whichever posts first moves
|
||||
* the through-date past the second's period, and the second finds its work already done.
|
||||
*/
|
||||
private void accrue(final BatchRunRow row, final LocalDate jobDate, final boolean odInterest) {
|
||||
final Party party = partyOf(row.getPartyId());
|
||||
|
||||
final LocalDate calculatedThrough = party.interestCalculatedThrough(odInterest);
|
||||
if (calculatedThrough != null && !calculatedThrough.isBefore(row.getToDate())) {
|
||||
return;
|
||||
}
|
||||
|
||||
party.accrueInterest(row.getAmount(), jobDate, odInterest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits the parked interest to the customer's balance and records it on the ledger.
|
||||
*
|
||||
* Only what the account is actually holding can be credited: two posting runs generated over the
|
||||
* 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) {
|
||||
final Party party = partyOf(row.getPartyId());
|
||||
if (party.accruedInterest(odInterest).compareTo(row.getAmount()) < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final BigDecimal newBalance = party.postInterest(row.getAmount(), odInterest);
|
||||
|
||||
// The same interest on the bank's own books. A party opened before DDS began opening real
|
||||
// accounts has none, and keeps its DDS balance alone rather than the run refusing to pay
|
||||
// everybody because of one old record.
|
||||
if (party.getSavingsAccountId() != null) {
|
||||
this.ddsLedger.postInterest(party.getSavingsAccountId(), batchRun.getJobDate(), row.getAmount(),
|
||||
(odInterest ? "DDS OD interest" : "DDS interest") + ", batch run #" + batchRun.getId());
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds what the agent earned to what they are owed, and withholds the TDS that went with it.
|
||||
* Guarded the same way as interest accrual: a period already calculated through is not paid again.
|
||||
*/
|
||||
private void accrueCommission(final BatchRunRow row, final LocalDate jobDate) {
|
||||
final Agent agent = agentOf(row.getAgentId());
|
||||
|
||||
final LocalDate calculatedThrough = agent.getLastCommissionDate();
|
||||
if (calculatedThrough != null && !calculatedThrough.isBefore(row.getToDate())) {
|
||||
return;
|
||||
}
|
||||
|
||||
final BigDecimal tds = row.getTdsAmount() == null ? BigDecimal.ZERO : row.getTdsAmount();
|
||||
agent.accrueCommission(row.getAmount(), tds, jobDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pays the agent what they are owed and records it on the ledger.
|
||||
*
|
||||
* The agent's DDS balance is deliberately not touched: that figure is cash collected from
|
||||
* 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) {
|
||||
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;
|
||||
}
|
||||
agent.payOutCommission(row.getAmount());
|
||||
|
||||
creditCommissionToSavings(agent, row.getAmount(), batchRun);
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the commission into the agent's own savings account.
|
||||
*
|
||||
* Until this ran, paying commission only zeroed a figure on the agent row: the money was "paid"
|
||||
* inside DDS and never arrived anywhere the agent could draw on, and no ledger entry was made.
|
||||
*
|
||||
* The account is checked again here rather than trusted from when it was chosen. It may have been
|
||||
* closed since, and agents on the books before those checks existed still carry accounts that
|
||||
* never passed them. Every reason it cannot take the credit is named, because the alternative is
|
||||
* what this replaced: the deposit was simply attempted, and the savings module answered with
|
||||
* "Transaction is not allowed. Account is not active" - true, but it tells an officer nothing
|
||||
* about which agent, which account, or what to do about it.
|
||||
*
|
||||
* Throwing rolls the whole run back. A commission run that cannot pay one agent must not half-pay
|
||||
* the rest.
|
||||
*/
|
||||
private void creditCommissionToSavings(final Agent agent, final BigDecimal amount, final BatchRun batchRun) {
|
||||
final String accountNo = agent.getAgentSavingAccountNo();
|
||||
if (accountNo == null || accountNo.isBlank()) {
|
||||
throw new AgentCommissionAccountUnusableException(agent.getAgentCode(), "no savings account is on file for them");
|
||||
}
|
||||
|
||||
// The account's own row, not the entity: loading the entity would drag its product in with it,
|
||||
// and the ledger loads the account properly for itself anyway.
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(COMMISSION_ACCOUNT_SQL, accountNo); // NOSONAR
|
||||
if (rows.isEmpty()) {
|
||||
throw new AgentCommissionAccountUnusableException(agent.getAgentCode(),
|
||||
"no account with the number " + accountNo + " exists, so the commission has nowhere to go");
|
||||
}
|
||||
|
||||
final Map<String, Object> account = rows.get(0);
|
||||
final int status = ((Number) account.get("status_enum")).intValue();
|
||||
final int depositType = ((Number) account.get("deposit_type_enum")).intValue();
|
||||
final Long productId = ((Number) account.get("product_id")).longValue();
|
||||
|
||||
if (depositType != SAVINGS_DEPOSIT_TYPE) {
|
||||
throw new AgentCommissionAccountUnusableException(agent.getAgentCode(), "account " + accountNo + " is a "
|
||||
+ (depositType == FIXED_DEPOSIT_TYPE ? "fixed" : "recurring") + " deposit, which cannot take a commission credit."
|
||||
+ " 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))) {
|
||||
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");
|
||||
}
|
||||
|
||||
// Active or dormant: a dormant account is still open and a deposit wakes it up. Everything
|
||||
// else - awaiting approval, closed, rejected - cannot take one, and this says which it is.
|
||||
if (status != ACTIVE_SAVINGS_STATUS && status != DORMANT_SAVINGS_STATUS) {
|
||||
throw new AgentCommissionAccountUnusableException(agent.getAgentCode(),
|
||||
"account " + accountNo + " is " + statusName(status) + " rather than active, so it cannot take a deposit yet");
|
||||
}
|
||||
|
||||
this.ddsLedger.payCommission(((Number) account.get("id")).longValue(), batchRun.getJobDate(), amount,
|
||||
"DDS commission, batch run #" + batchRun.getId());
|
||||
}
|
||||
|
||||
/** Fineract's savings statuses, named the way the screens name them. */
|
||||
private String statusName(final int status) {
|
||||
return switch (status) {
|
||||
case 100 -> "still awaiting approval";
|
||||
case 200 -> "approved but not yet activated";
|
||||
case 303 -> "dormant";
|
||||
case 304 -> "escheated";
|
||||
case 400 -> "withdrawn";
|
||||
case 500 -> "rejected";
|
||||
case 600 -> "closed";
|
||||
default -> "in state " + status;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Automatic posting - the Account Type screen's "Auto Interest Post" setting
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Posts interest for every scheme that is due today.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
@Transactional
|
||||
@Override
|
||||
public int postDueAutoInterest(final LocalDate businessDate) {
|
||||
int posted = 0;
|
||||
|
||||
for (final DdsAccountType accountType : this.ddsAccountTypeRepository.findDueForAutoInterestPost()) {
|
||||
if (!isDueOn(accountType, businessDate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final String subType = accountType.getAccountTypeCode();
|
||||
calculateAndPost(INTEREST_CALCULATION, businessDate, subType);
|
||||
calculateAndPost(INTEREST_POSTING, businessDate, subType);
|
||||
posted++;
|
||||
}
|
||||
|
||||
return posted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given month is one this frequency posts in.
|
||||
*
|
||||
* Monthly is every month. The rest follow the Indian financial year, so quarters close in June,
|
||||
* September, December and March, half-years in September and March, and the year in March.
|
||||
* <p>
|
||||
* Every scheme in use today is Monthly; the others are here so the setting is not silently
|
||||
* ignored, and the month choice for them is worth confirming with the business before any scheme
|
||||
* relies on it.
|
||||
*/
|
||||
private boolean monthIsDue(final String frequency, final LocalDate date) {
|
||||
final int month = date.getMonthValue();
|
||||
return switch (frequency == null ? "Monthly" : frequency) {
|
||||
case "Quarterly" -> month == 6 || month == 9 || month == 12 || month == 3;
|
||||
case "Half Yearly" -> month == 9 || month == 3;
|
||||
case "Yearly" -> month == 3;
|
||||
default -> true;
|
||||
};
|
||||
}
|
||||
|
||||
/** One half of an automatic posting: work the rows out, save them, then apply them. */
|
||||
/**
|
||||
* Brings every matured account's interest up to its maturity date.
|
||||
*
|
||||
* Interest otherwise arrives only when a posting run happens, which is monthly, set per scheme,
|
||||
* and can be switched off - so a term ending between two runs leaves its last stretch uncredited
|
||||
* and a payout made in the meantime pays the customer less than they earned. Every account on a
|
||||
* scheme with automatic posting turned off had exactly that gap.
|
||||
*
|
||||
* Maturity is the one date that cannot wait for the next run, so it is done here, on the night
|
||||
* after each account's term ends, whatever its scheme says about automatic posting. By the time
|
||||
* the account reaches the clerk's list in the morning it is already whole, and the figure on
|
||||
* screen is the figure that will be paid.
|
||||
*/
|
||||
@Transactional
|
||||
@Override
|
||||
public int postMaturityInterest(final LocalDate businessDate) {
|
||||
int posted = 0;
|
||||
for (final Party party : this.partyRepository.findMaturedWithInterestOutstanding(businessDate)) {
|
||||
if (postInterestThrough(party, party.getMaturityDate()).compareTo(BigDecimal.ZERO) > 0) {
|
||||
posted++;
|
||||
}
|
||||
}
|
||||
return posted;
|
||||
}
|
||||
|
||||
/** The holder's own KYC, read from the bank's record rather than anything DDS keeps of its own. */
|
||||
private static final String KYC_VERIFIED_SQL = "select count(*) from m_client_kyc "
|
||||
+ "where client_id = ? and kyc_status = 300 and (expiry_date is null or expiry_date >= current_date)";
|
||||
|
||||
private static final int MAJORITY_AGE = 18;
|
||||
|
||||
/**
|
||||
* Turns over the minors' accounts whose holders have come of age and been identified.
|
||||
*
|
||||
* A minor's account is operated by a guardian because a minor cannot contract. At eighteen that
|
||||
* stops being true: the customer holds the account in their own right and the guardian's
|
||||
* authority ends. Left as it was, the bank would go on dealing with somebody who no longer stands
|
||||
* for them.
|
||||
*
|
||||
* The birthday alone is not enough. An eighteen-year-old the bank has never identified is somebody
|
||||
* it cannot pay, so the turnover waits on their own KYC being verified - and until it is, the
|
||||
* payout refuses while collections carry on. Nothing is written for an account still waiting.
|
||||
*/
|
||||
@Transactional
|
||||
@Override
|
||||
public int promoteMinorsWhoHaveComeOfAge(final LocalDate businessDate) {
|
||||
final LocalDate bornOnOrBefore = businessDate.minusYears(MAJORITY_AGE);
|
||||
int promoted = 0;
|
||||
for (final Party party : this.partyRepository.findMinorsWhoHaveComeOfAge(bornOnOrBefore)) {
|
||||
final Integer verified = this.jdbcTemplate.queryForObject(KYC_VERIFIED_SQL, Integer.class, party.clientId()); // NOSONAR
|
||||
if (verified == null || verified == 0) {
|
||||
continue;
|
||||
}
|
||||
party.becomeAdult();
|
||||
this.partyRepository.save(party);
|
||||
promoted++;
|
||||
}
|
||||
return promoted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Works out a single account's interest up to a date and credits it, returning what was paid.
|
||||
*
|
||||
* The same daily-product arithmetic the batch uses - the calculation lives in one class so the
|
||||
* figure cannot depend on which route asked for it. What differs is only the reach: a run works
|
||||
* to a common job date across many accounts, this works to one account's own date.
|
||||
*
|
||||
* Returns zero, having done nothing, when interest is already current or the sum rounds away.
|
||||
* Nothing is written in that case - an account that owes nothing should not carry a nil posting.
|
||||
*/
|
||||
@Transactional
|
||||
@Override
|
||||
public BigDecimal postInterestThrough(final Party party, final LocalDate through) {
|
||||
final BigDecimal rate = party.getInterestRate();
|
||||
if (through == null || rate == null || rate.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
final LocalDate accrueFrom = party.interestCalculatedThrough(false) != null ? party.interestCalculatedThrough(false)
|
||||
: party.getOpeningDate();
|
||||
if (!accrueFrom.isBefore(through)) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
final List<PartyTransaction> movements = this.partyTransactionRepository.findForPartiesUpTo(List.of(party.getId()), through);
|
||||
final BigDecimal dailyProduct = DdsInterestCalculation.dailyProduct(party.getOpeningBalance(), movementsOf(movements), accrueFrom,
|
||||
through);
|
||||
final BigDecimal interest = DdsInterestCalculation.interestOn(dailyProduct, rate);
|
||||
if (interest.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
party.accrueInterest(interest, through, false);
|
||||
final BigDecimal newBalance = party.postInterest(interest, false);
|
||||
this.partyRepository.saveAndFlush(party);
|
||||
|
||||
// The same interest on the bank's own books. A party opened before DDS began opening real
|
||||
// accounts has none, and keeps its DDS balance alone rather than the posting being refused.
|
||||
if (party.getSavingsAccountId() != null) {
|
||||
this.ddsLedger.postInterest(party.getSavingsAccountId(), through, interest, "DDS interest to maturity");
|
||||
}
|
||||
|
||||
this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), through, "INTEREST_POST", DIRECTION_CREDIT, null,
|
||||
null, null, interest, null, null, "Interest to maturity", newBalance));
|
||||
|
||||
return interest;
|
||||
}
|
||||
|
||||
private void calculateAndPost(final String jobType, final LocalDate jobDate, final String subType) {
|
||||
final BatchRun batchRun = new BatchRun(jobType, "All", jobDate, 1L, Long.MAX_VALUE, 1L, Long.MAX_VALUE, LocalDate.now(),
|
||||
subType);
|
||||
|
||||
if (INTEREST_CALCULATION.equals(jobType)) {
|
||||
generateInterestRows(batchRun, 1L, Long.MAX_VALUE, 1L, Long.MAX_VALUE, jobDate, false, null, subType);
|
||||
} else {
|
||||
generateInterestPostingRows(batchRun, 1L, Long.MAX_VALUE, 1L, Long.MAX_VALUE, jobDate, false, null, subType);
|
||||
}
|
||||
|
||||
this.batchRunRepository.saveAndFlush(batchRun);
|
||||
|
||||
// A run that found nothing still gets recorded and marked posted, so the log shows the job
|
||||
// looked and there was nothing to do - rather than leaving a run that reads as unfinished.
|
||||
applyRows(batchRun);
|
||||
batchRun.markPosted(LocalDate.now());
|
||||
this.batchRunRepository.saveAndFlush(batchRun);
|
||||
}
|
||||
|
||||
private Party partyOf(final Long partyId) {
|
||||
return this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId));
|
||||
}
|
||||
|
||||
private Agent agentOf(final Long agentId) {
|
||||
return this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
|
||||
}
|
||||
}
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.batch.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
||||
import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType;
|
||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Runs a posting batch away from the request that asked for it.
|
||||
*
|
||||
* Posting credits every account on the run one at a time, and each credit costs what a savings
|
||||
* deposit costs - the account is loaded with its transactions and its running balances worked out
|
||||
* again. Measured on this build, four hundred accounts carrying ninety days of collections took two
|
||||
* minutes, and the cost climbs faster than the count: twice the accounts took four and a half times
|
||||
* as long. A branch of any size would leave the clerk watching a still screen, unable to tell a slow
|
||||
* run from a hung one, and pressing the button again.
|
||||
*
|
||||
* So the request returns as soon as the run is marked under way, and the crediting happens here. The
|
||||
* arithmetic is untouched - the same rows, the same amounts, the same ledger entries. What changes is
|
||||
* only who waits for it.
|
||||
*
|
||||
* A background thread starts with none of the context a request carries, and three pieces of it
|
||||
* matter. Without the tenant there is no database to write to at all. Without the business date the
|
||||
* postings would be dated by the wall clock rather than the day the bank is working on. Without the
|
||||
* signed-in user every row would fail its not-null audit column. All three are taken from the calling
|
||||
* thread and put back here.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DdsBatchPostingRunner {
|
||||
|
||||
/**
|
||||
* Deferred on purpose. The service hands work to this runner and the runner calls back into it,
|
||||
* so resolving either eagerly would have them waiting on each other at startup.
|
||||
*/
|
||||
private final ObjectProvider<BatchRunWritePlatformService> batchRunService;
|
||||
|
||||
@Async
|
||||
public void post(final Long batchRunId, final FineractPlatformTenant tenant, final HashMap<BusinessDateType, LocalDate> businessDates,
|
||||
final String dataSourceContext, final SecurityContext securityContext) {
|
||||
|
||||
ThreadLocalContextUtil.setTenant(tenant);
|
||||
ThreadLocalContextUtil.setBusinessDates(businessDates);
|
||||
if (dataSourceContext != null) {
|
||||
ThreadLocalContextUtil.setDataSourceContext(dataSourceContext);
|
||||
}
|
||||
SecurityContextHolder.setContext(securityContext);
|
||||
|
||||
try {
|
||||
this.batchRunService.getObject().applyAndMarkPosted(batchRunId);
|
||||
log.info("DDS batch run #{} posted", batchRunId);
|
||||
} catch (final Exception e) {
|
||||
// Recorded against the run rather than only logged: a run that dies in the background has
|
||||
// nowhere else to say so, and a clerk who is not told simply presses the button again.
|
||||
log.error("DDS batch run #{} failed while posting", batchRunId, e);
|
||||
markFailedQuietly(batchRunId, e);
|
||||
} finally {
|
||||
// The pool hands this thread on to whatever comes next, so everything set above has to go
|
||||
// - a tenant left behind would have the next run writing to the wrong bank's database.
|
||||
SecurityContextHolder.clearContext();
|
||||
ThreadLocalContextUtil.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the reason onto the run, in a transaction of its own.
|
||||
*
|
||||
* The one that failed has rolled back, so this needs a fresh one - and if even that cannot be
|
||||
* written there is nothing further to be done but log it, since throwing from here would lose the
|
||||
* original failure.
|
||||
*/
|
||||
private void markFailedQuietly(final Long batchRunId, final Exception cause) {
|
||||
try {
|
||||
this.batchRunService.getObject().markPostingFailed(batchRunId, cause.getMessage());
|
||||
} catch (final Exception ignored) {
|
||||
log.error("DDS batch run #{} could not record its failure", batchRunId, ignored);
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -20,14 +20,19 @@ package org.apache.fineract.portfolio.dds.batch.starter;
|
||||
|
||||
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
|
||||
import org.apache.fineract.organisation.agent.domain.AgentRepository;
|
||||
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountTypeRepository;
|
||||
import org.apache.fineract.portfolio.dds.batch.domain.BatchRunRepository;
|
||||
import org.apache.fineract.portfolio.dds.batch.serialization.BatchRunCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.DdsBatchPostingRunner;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunReadPlatformService;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformServiceJpaRepositoryImpl;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
|
||||
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
|
||||
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -46,8 +51,11 @@ public class DdsBatchConfiguration {
|
||||
@ConditionalOnMissingBean(BatchRunWritePlatformService.class)
|
||||
public BatchRunWritePlatformService batchRunWritePlatformService(BatchRunCommandFromApiJsonDeserializer fromApiJsonDeserializer,
|
||||
BatchRunRepository batchRunRepository, PartyRepository partyRepository, AgentRepository agentRepository,
|
||||
PartyTransactionRepository partyTransactionRepository, CommissionSlabRepository commissionSlabRepository) {
|
||||
PartyTransactionRepository partyTransactionRepository, CommissionSlabRepository commissionSlabRepository,
|
||||
DdsAccountTypeRepository ddsAccountTypeRepository, DdsLedger ddsLedger, DdsBatchPostingRunner postingRunner,
|
||||
JdbcTemplate jdbcTemplate, ObjectProvider<PartyWritePlatformService> partyWritePlatformService) {
|
||||
return new BatchRunWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, batchRunRepository, partyRepository,
|
||||
agentRepository, partyTransactionRepository, commissionSlabRepository);
|
||||
agentRepository, partyTransactionRepository, commissionSlabRepository, ddsAccountTypeRepository, ddsLedger, postingRunner,
|
||||
jdbcTemplate, partyWritePlatformService);
|
||||
}
|
||||
}
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.api;
|
||||
|
||||
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;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.LocalDate;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.dds.collection.data.AgentCollectionSummaryData;
|
||||
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/dds/collectionentries")
|
||||
@Component
|
||||
@Tag(name = "DDS Collection Entries", description = "What an agent wrote down on their round, before the branch has counted the cash. "
|
||||
+ "These rows move no money: no customer is credited, no agent's cash recorded and no ledger entry written until a clerk settles "
|
||||
+ "the agent's day, and what that writes is an ordinary DDS collection.")
|
||||
@RequiredArgsConstructor
|
||||
public class CollectionEntriesApiResource {
|
||||
|
||||
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DDSCOLLECTIONENTRY";
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final CollectionEntryReadPlatformService readPlatformService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "An agent's written-down collections for a date", description = "Returns the agent's entries for the date with "
|
||||
+ "what they add up to. The agent's own screen shows it back as the record of their round; the counter reads the same figures "
|
||||
+ "to count cash against. Defaults to today when no date is given.")
|
||||
public AgentCollectionSummaryData retrieveAgentDay(@QueryParam("agentId") @Parameter(description = "agentId") final Long agentId,
|
||||
@QueryParam("entryDate") @Parameter(description = "entryDate as yyyy-MM-dd") final String entryDate) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
final LocalDate date = entryDate == null || entryDate.isBlank() ? DateUtils.getBusinessLocalDate() : LocalDate.parse(entryDate);
|
||||
return readPlatformService.retrieveAgentDay(agentId, date);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Write down collections", description = "Mandatory: agentId, entryDate, entries (array of {partyCode, amount, "
|
||||
+ "transactionMode, chequeNo, tokenNo, description}). Nothing is credited and no cash is recorded - this is the agent's note, "
|
||||
+ "and it stands on its own until the day is settled.")
|
||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.createDdsCollectionEntries() //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("settle")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Settle an agent's day", description = "Mandatory: agentId, entryDate. Posts every outstanding entry for that "
|
||||
+ "agent and date as a real collection - all of them or none. No amount is accepted: what is settled is what the entries say.")
|
||||
public CommandProcessingResult settle(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.settleDdsCollectionEntries() //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("{entryId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Take back a written-down collection", description = "Cancels an entry that has not been settled. A settled "
|
||||
+ "entry stands behind a real credit and is reversed on the ledger, not withdrawn here.")
|
||||
public CommandProcessingResult cancel(@PathParam("entryId") @Parameter(description = "entryId") final Long entryId) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.cancelDdsCollectionEntry(entryId) //
|
||||
.withJson("{}") //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.domain;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface CollectionEntryRepository extends JpaRepository<CollectionEntry, Long> {
|
||||
|
||||
/**
|
||||
* What this agent still has outstanding on this date - the list a clerk counts the cash against,
|
||||
* and the list the agent's own screen shows back to them.
|
||||
*
|
||||
* Ordered by id, which is the order they were written in. An agent checking their round against
|
||||
* their bag reads it the way they walked it.
|
||||
*/
|
||||
@Query("select e from CollectionEntry e where e.agent.id = :agentId and e.entryDate = :entryDate "
|
||||
+ "and e.status = 'PENDING' order by e.id")
|
||||
List<CollectionEntry> findPendingFor(@Param("agentId") Long agentId, @Param("entryDate") LocalDate entryDate);
|
||||
|
||||
/**
|
||||
* Everything written for this agent on this date, settled or not.
|
||||
*
|
||||
* Separate from the pending list because the two answer different questions. A clerk counting
|
||||
* cash wants only what is outstanding; an agent looking back at their day wants the whole day,
|
||||
* including what has already been handed in.
|
||||
*/
|
||||
@Query("select e from CollectionEntry e where e.agent.id = :agentId and e.entryDate = :entryDate order by e.id")
|
||||
List<CollectionEntry> findAllFor(@Param("agentId") Long agentId, @Param("entryDate") LocalDate entryDate);
|
||||
|
||||
/**
|
||||
* Whether this account already has money written against it on this date.
|
||||
*
|
||||
* A daily scheme takes one instalment a day, so a second line for the same account on the same
|
||||
* day is nearly always the same collection entered twice - a dropped connection, a retried tap.
|
||||
* Caught here rather than let through, because two lines become two credits the moment a clerk
|
||||
* confirms, and by then the customer's balance is wrong in a way only a reversal can fix.
|
||||
*/
|
||||
@Query("select count(e) from CollectionEntry e where e.party.id = :partyId and e.entryDate = :entryDate "
|
||||
+ "and e.status <> 'CANCELLED'")
|
||||
long countLiveEntriesFor(@Param("partyId") Long partyId, @Param("entryDate") LocalDate entryDate);
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "DDSCOLLECTIONENTRY", action = "CANCEL")
|
||||
public class CancelCollectionEntryCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final CollectionEntryWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public CancelCollectionEntryCommandHandler(final CollectionEntryWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.cancelEntry(command.entityId(), command);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "DDSCOLLECTIONENTRY", action = "CREATE")
|
||||
public class CreateCollectionEntriesCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final CollectionEntryWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public CreateCollectionEntriesCommandHandler(final CollectionEntryWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.createEntries(command);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "DDSCOLLECTIONENTRY", action = "SETTLE")
|
||||
public class SettleCollectionEntriesCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final CollectionEntryWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public SettleCollectionEntriesCommandHandler(final CollectionEntryWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.settleAgentDay(command);
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.serialization;
|
||||
|
||||
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;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.portfolio.dds.transaction.domain.DdsTransactionMode;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* What a written-down collection has to say before it is worth writing down.
|
||||
*
|
||||
* Deliberately narrow. This is the agent's own note, not a posting, so the questions asked are only
|
||||
* the ones that can be answered away from the branch: is there an account named, an amount, and a
|
||||
* form the money came in. Whether the cash is really there is settled at the counter, and no amount
|
||||
* of checking here would bring that forward.
|
||||
*/
|
||||
@Component
|
||||
public final class CollectionEntryCommandFromApiJsonDeserializer {
|
||||
|
||||
private static final String RESOURCE = "ddscollectionentry";
|
||||
|
||||
private static final String ENTRIES = "entries";
|
||||
|
||||
private static final Set<String> SUPPORTED_CREATE_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList("agentId", "entryDate", ENTRIES, "dateFormat", "locale"));
|
||||
|
||||
private static final Set<String> SUPPORTED_ENTRY_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList("partyCode", "amount", "transactionMode", "chequeNo", "tokenNo", "description"));
|
||||
|
||||
private static final Set<String> SUPPORTED_SETTLE_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList("agentId", "entryDate", "dateFormat", "locale"));
|
||||
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
|
||||
@Autowired
|
||||
public CollectionEntryCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
|
||||
this.fromApiJsonHelper = fromApiJsonHelper;
|
||||
}
|
||||
|
||||
public void validateForCreate(final String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
throw new InvalidJsonException();
|
||||
}
|
||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_CREATE_PARAMETERS);
|
||||
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE);
|
||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
||||
|
||||
v.reset().parameter("agentId").value(this.fromApiJsonHelper.extractLongNamed("agentId", element)).notNull()
|
||||
.integerGreaterThanZero();
|
||||
v.reset().parameter("entryDate").value(this.fromApiJsonHelper.extractLocalDateNamed("entryDate", element)).notNull();
|
||||
|
||||
if (!this.fromApiJsonHelper.parameterExists(ENTRIES, element)) {
|
||||
v.reset().parameter(ENTRIES).value(null).notNull();
|
||||
} else {
|
||||
final var rows = element.getAsJsonObject().getAsJsonArray(ENTRIES);
|
||||
v.reset().parameter(ENTRIES).value(rows).jsonArrayNotEmpty();
|
||||
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
final JsonObject row = rows.get(i).getAsJsonObject();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(row, SUPPORTED_ENTRY_PARAMETERS);
|
||||
|
||||
v.reset().parameter(ENTRIES).parameterAtIndexArray("partyCode", i).value(stringOf(row, "partyCode")).notBlank();
|
||||
|
||||
v.reset().parameter(ENTRIES).parameterAtIndexArray("amount", i).value(decimalOf(row)).notNull().positiveAmount();
|
||||
|
||||
final String mode = stringOf(row, "transactionMode");
|
||||
v.reset().parameter(ENTRIES).parameterAtIndexArray("transactionMode", i).value(mode).notBlank();
|
||||
// The row has to be named in the message: a round is entered a line at a time and the
|
||||
// agent needs to know which line the screen is complaining about.
|
||||
v.reset().parameter(ENTRIES).value(mode).ignoreIfNull().matchesRegularExpression(DdsTransactionMode.acceptedPattern(),
|
||||
"Row " + (i + 1) + ": payment mode must be one of " + DdsTransactionMode.labelsForMessage());
|
||||
|
||||
v.reset().parameter(ENTRIES).parameterAtIndexArray("chequeNo", i).value(stringOf(row, "chequeNo")).ignoreIfNull()
|
||||
.notExceedingLengthOf(50);
|
||||
v.reset().parameter(ENTRIES).parameterAtIndexArray("tokenNo", i).value(stringOf(row, "tokenNo")).ignoreIfNull()
|
||||
.notExceedingLengthOf(50);
|
||||
v.reset().parameter(ENTRIES).parameterAtIndexArray("description", i).value(stringOf(row, "description")).ignoreIfNull()
|
||||
.notExceedingLengthOf(200);
|
||||
}
|
||||
}
|
||||
|
||||
throwIfErrors(errors);
|
||||
}
|
||||
|
||||
public void validateForSettle(final String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
throw new InvalidJsonException();
|
||||
}
|
||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_SETTLE_PARAMETERS);
|
||||
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE);
|
||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
||||
|
||||
v.reset().parameter("agentId").value(this.fromApiJsonHelper.extractLongNamed("agentId", element)).notNull()
|
||||
.integerGreaterThanZero();
|
||||
v.reset().parameter("entryDate").value(this.fromApiJsonHelper.extractLocalDateNamed("entryDate", element)).notNull();
|
||||
|
||||
// No amount is asked for. What is settled is what the entries say, to the rupee - a figure
|
||||
// typed here would be a second opinion about a total the entries already give, and the two
|
||||
// could differ.
|
||||
throwIfErrors(errors);
|
||||
}
|
||||
|
||||
private String stringOf(final JsonObject json, final String field) {
|
||||
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
|
||||
}
|
||||
|
||||
private BigDecimal decimalOf(final JsonObject json) {
|
||||
return json.has("amount") && !json.get("amount").isJsonNull() ? json.get("amount").getAsBigDecimal() : null;
|
||||
}
|
||||
|
||||
private void throwIfErrors(final List<ApiParameterError> errors) {
|
||||
if (!errors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import org.apache.fineract.portfolio.dds.collection.data.AgentCollectionSummaryData;
|
||||
|
||||
public interface CollectionEntryReadPlatformService {
|
||||
|
||||
/**
|
||||
* An agent's written-down collections for a date, with what they add up to.
|
||||
*
|
||||
* One call serves both screens. The agent's own page shows it back to them as the running record
|
||||
* of their round; the clerk's settlement screen reads the same figures to count cash against.
|
||||
* Two calls would be two chances for the two screens to disagree about the same day.
|
||||
*/
|
||||
AgentCollectionSummaryData retrieveAgentDay(Long agentId, LocalDate entryDate);
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
|
||||
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.portfolio.dds.collection.data.AgentCollectionSummaryData;
|
||||
import org.apache.fineract.portfolio.dds.collection.data.CollectionEntryData;
|
||||
import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntry;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class CollectionEntryReadPlatformServiceImpl implements CollectionEntryReadPlatformService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final AgentRepository agentRepository;
|
||||
|
||||
private static final class EntryMapper implements RowMapper<CollectionEntryData> {
|
||||
|
||||
String schema() {
|
||||
// The party is joined in for its code, name and balance: every reader of this list is
|
||||
// checking a line against a person, and an id on its own tells them nothing. The balance
|
||||
// is the account's as it stands - these entries have not been posted, so it does not
|
||||
// include them, and the screens say as much.
|
||||
return " e.id as id, e.agent_id as agentId, a.agent_code as agentCode, a.agent_name as agentName, "
|
||||
+ " e.party_id as partyId, p.party_code as partyCode, p.party_name as partyName, "
|
||||
+ " p.current_balance as partyBalance, p.daily_deposit_amount as dailyDepositAmount, "
|
||||
+ " e.entry_date as entryDate, e.amount as amount, e.transaction_mode as transactionMode, "
|
||||
+ " e.cheque_no as chequeNo, e.token_no as tokenNo, e.description as description, "
|
||||
+ " e.status as status, e.settled_on as settledOn, e.party_transaction_id as partyTransactionId "
|
||||
+ " from m_dds_collection_entry e "
|
||||
+ " join m_dds_agent a on a.id = e.agent_id "
|
||||
+ " join m_dds_party p on p.id = e.party_id";
|
||||
}
|
||||
|
||||
@Override
|
||||
public CollectionEntryData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||
return CollectionEntryData.builder() //
|
||||
.id(rs.getLong("id")) //
|
||||
.agentId(rs.getLong("agentId")) //
|
||||
.agentCode(rs.getString("agentCode")) //
|
||||
.agentName(rs.getString("agentName")) //
|
||||
.partyId(rs.getLong("partyId")) //
|
||||
.partyCode(rs.getString("partyCode")) //
|
||||
.partyName(rs.getString("partyName")) //
|
||||
.partyBalance(rs.getBigDecimal("partyBalance")) //
|
||||
.dailyDepositAmount(rs.getBigDecimal("dailyDepositAmount")) //
|
||||
.entryDate(JdbcSupport.getLocalDate(rs, "entryDate")) //
|
||||
.amount(rs.getBigDecimal("amount")) //
|
||||
.transactionMode(rs.getString("transactionMode")) //
|
||||
.chequeNo(rs.getString("chequeNo")) //
|
||||
.tokenNo(rs.getString("tokenNo")) //
|
||||
.description(rs.getString("description")) //
|
||||
.status(rs.getString("status")) //
|
||||
.settledOn(JdbcSupport.getLocalDate(rs, "settledOn")) //
|
||||
.partyTransactionId(JdbcSupport.getLong(rs, "partyTransactionId")) //
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgentCollectionSummaryData retrieveAgentDay(final Long agentId, final LocalDate entryDate) {
|
||||
// Named first, so an agent id that matches nothing is refused here rather than quietly
|
||||
// returning an empty day - which reads as "collected nothing", and that is an answer a clerk
|
||||
// would act on.
|
||||
final Agent agent = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
|
||||
|
||||
final EntryMapper mapper = new EntryMapper();
|
||||
// Cancelled lines are left out. They were taken back before anything was posted, so they are
|
||||
// not part of the day's record and would only be counted by somebody adding the column up.
|
||||
final String sql = "select " + mapper.schema() + " where e.agent_id = ? and e.entry_date = ? "
|
||||
+ " and e.status <> '" + CollectionEntry.CANCELLED + "' order by e.id";
|
||||
final List<CollectionEntryData> entries = this.jdbcTemplate.query(sql, mapper, agentId, entryDate);
|
||||
|
||||
int pendingCount = 0;
|
||||
int settledCount = 0;
|
||||
BigDecimal expected = BigDecimal.ZERO;
|
||||
BigDecimal settled = BigDecimal.ZERO;
|
||||
|
||||
for (final CollectionEntryData entry : entries) {
|
||||
if (CollectionEntry.PENDING.equals(entry.getStatus())) {
|
||||
pendingCount++;
|
||||
expected = expected.add(entry.getAmount());
|
||||
} else if (CollectionEntry.SETTLED.equals(entry.getStatus())) {
|
||||
settledCount++;
|
||||
settled = settled.add(entry.getAmount());
|
||||
}
|
||||
}
|
||||
|
||||
return AgentCollectionSummaryData.builder() //
|
||||
.agentId(agent.getId()) //
|
||||
.agentCode(agent.getAgentCode()) //
|
||||
.agentName(agent.getAgentName()) //
|
||||
.entryDate(entryDate) //
|
||||
.pendingCount(pendingCount) //
|
||||
.expectedAmount(expected) //
|
||||
.settledCount(settledCount) //
|
||||
.settledAmount(settled) //
|
||||
.entries(entries) //
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.service;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
|
||||
public interface CollectionEntryWritePlatformService {
|
||||
|
||||
/**
|
||||
* Writes down what an agent says they collected. Moves no money whatsoever.
|
||||
*
|
||||
* Accepts one entry or a round of them. The checks are the ones that can be made without the
|
||||
* cash in hand - that the account exists, is open, and is this agent's to collect from - because
|
||||
* an entry the branch will refuse hours later is better refused at the customer's door, while
|
||||
* the agent is still standing there.
|
||||
*/
|
||||
CommandProcessingResult createEntries(JsonCommand command);
|
||||
|
||||
/** Takes back a line that was never posted. Refused once the entry has been settled. */
|
||||
CommandProcessingResult cancelEntry(Long entryId, JsonCommand command);
|
||||
|
||||
/**
|
||||
* Turns an agent's outstanding entries for a date into real credits, all together.
|
||||
*
|
||||
* This is the moment the money becomes the bank's business: the clerk has the cash in front of
|
||||
* them, counted against what the entries add up to. All or none - a settlement that posted half
|
||||
* a round would leave the branch holding cash it could not account for against a list that no
|
||||
* longer matched.
|
||||
*/
|
||||
CommandProcessingResult settleAgentDay(JsonCommand command);
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.service;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
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.portfolio.dds.collection.domain.CollectionEntry;
|
||||
import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntryRepository;
|
||||
import org.apache.fineract.portfolio.dds.collection.exception.CollectionEntryNotFoundException;
|
||||
import org.apache.fineract.portfolio.dds.collection.exception.CollectionEntryNotPendingException;
|
||||
import org.apache.fineract.portfolio.dds.collection.exception.NothingToSettleException;
|
||||
import org.apache.fineract.portfolio.dds.collection.serialization.CollectionEntryCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
|
||||
import org.apache.fineract.portfolio.dds.party.exception.PartyAlreadyClosedException;
|
||||
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.exception.PartyNotCollectedByAgentException;
|
||||
import org.apache.fineract.portfolio.dds.transaction.service.PartyTransactionWritePlatformService;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Writing down collections, taking them back, and turning a day's worth into real credits.
|
||||
*
|
||||
* The division of labour here is the whole point of the feature. Everything up to the settlement
|
||||
* writes rows in one table and touches nothing else - no balance, no ledger, no agent's cash. The
|
||||
* settlement is where it becomes the bank's business, and what it does there is not its own version
|
||||
* of a posting but the same one the counter has always used.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class CollectionEntryWritePlatformServiceJpaRepositoryImpl implements CollectionEntryWritePlatformService {
|
||||
|
||||
private final CollectionEntryCommandFromApiJsonDeserializer fromApiJsonDeserializer;
|
||||
private final CollectionEntryRepository collectionEntryRepository;
|
||||
private final AgentRepository agentRepository;
|
||||
private final PartyRepository partyRepository;
|
||||
private final PartyTransactionWritePlatformService partyTransactionWritePlatformService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult createEntries(final JsonCommand command) {
|
||||
this.fromApiJsonDeserializer.validateForCreate(command.json());
|
||||
|
||||
final Agent agent = agentOf(command.longValueOfParameterNamed("agentId"));
|
||||
final LocalDate entryDate = command.localDateValueOfParameterNamed("entryDate");
|
||||
|
||||
final JsonArray rows = command.arrayOfParameterNamed("entries");
|
||||
final List<Long> written = new ArrayList<>(rows.size());
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
final JsonObject row = rows.get(i).getAsJsonObject();
|
||||
final Party party = collectableParty(stringOf(row, "partyCode"), agent);
|
||||
final BigDecimal amount = row.get("amount").getAsBigDecimal();
|
||||
|
||||
final CollectionEntry entry = new CollectionEntry(agent, party, entryDate, amount,
|
||||
DdsTransactionMode.canonicalOrOriginal(stringOf(row, "transactionMode")), stringOf(row, "chequeNo"),
|
||||
stringOf(row, "tokenNo"), stringOf(row, "description"));
|
||||
|
||||
written.add(this.collectionEntryRepository.saveAndFlush(entry).getId());
|
||||
total = total.add(amount);
|
||||
}
|
||||
|
||||
final Map<String, Object> changes = new LinkedHashMap<>();
|
||||
changes.put("entriesWritten", written.size());
|
||||
changes.put("totalAmount", total);
|
||||
changes.put("entryIds", written);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(agent.getId()).with(changes).build();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult cancelEntry(final Long entryId, final JsonCommand command) {
|
||||
final CollectionEntry entry = this.collectionEntryRepository.findById(entryId)
|
||||
.orElseThrow(() -> new CollectionEntryNotFoundException(entryId));
|
||||
|
||||
if (!entry.isPending()) {
|
||||
throw new CollectionEntryNotPendingException(entryId, entry.getStatus());
|
||||
}
|
||||
|
||||
entry.markCancelled();
|
||||
this.collectionEntryRepository.saveAndFlush(entry);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(entryId).build();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult settleAgentDay(final JsonCommand command) {
|
||||
this.fromApiJsonDeserializer.validateForSettle(command.json());
|
||||
|
||||
final Agent agent = agentOf(command.longValueOfParameterNamed("agentId"));
|
||||
final LocalDate entryDate = command.localDateValueOfParameterNamed("entryDate");
|
||||
|
||||
final List<CollectionEntry> pending = this.collectionEntryRepository.findPendingFor(agent.getId(), entryDate);
|
||||
if (pending.isEmpty()) {
|
||||
throw new NothingToSettleException(agent.getAgentCode(), entryDate.toString());
|
||||
}
|
||||
|
||||
final LocalDate settledOn = DateUtils.getBusinessLocalDate();
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
|
||||
for (final CollectionEntry entry : pending) {
|
||||
// Asked again, now. An account can be closed, or moved to another agent, between the
|
||||
// round and the counter - and this is the moment the money actually lands, so this is the
|
||||
// moment the account has to be fit to receive it.
|
||||
final Party party = collectableParty(entry.getParty().getPartyCode(), agent);
|
||||
|
||||
// Posted through the counter's own code, not a copy of it. The entry is the note; what
|
||||
// this writes is an ordinary collection, indistinguishable on the ledger from one typed
|
||||
// at the branch - because that is exactly what it is.
|
||||
final PartyTransaction transaction = this.partyTransactionWritePlatformService.postSingleCollection(agent, party,
|
||||
entry.getEntryDate(), entry.getAmount(), entry.getTransactionMode(), null, null, entry.getChequeNo(),
|
||||
entry.getTokenNo(), entry.getDescription());
|
||||
|
||||
entry.markSettled(settledOn, transaction.getId());
|
||||
this.collectionEntryRepository.save(entry);
|
||||
total = total.add(entry.getAmount());
|
||||
}
|
||||
|
||||
final Map<String, Object> changes = new LinkedHashMap<>();
|
||||
changes.put("entriesSettled", pending.size());
|
||||
changes.put("totalAmount", total);
|
||||
changes.put("settledOn", settledOn);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(agent.getId()).with(changes).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* The account this line names, if this agent may collect from it at all.
|
||||
*
|
||||
* The same three questions the counter asks, asked here as well - once when the agent writes the
|
||||
* line down and again when the clerk settles it. Asking early is what lets an agent be told at
|
||||
* the customer's door rather than hours later at the branch; asking again is what stops a line
|
||||
* written this morning being posted this evening onto an account that has since been closed.
|
||||
*/
|
||||
/** Named rather than found empty-handed: an id that matches no agent is a caller's mistake, not a day with nothing in it. */
|
||||
private Agent agentOf(final Long agentId) {
|
||||
return this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
|
||||
}
|
||||
|
||||
private Party collectableParty(final String partyCode, final Agent agent) {
|
||||
final Party party = this.partyRepository.findByPartyCode(partyCode).orElseThrow(() -> new PartyNotFoundException(partyCode));
|
||||
if (party.isClosed()) {
|
||||
throw new PartyAlreadyClosedException(partyCode);
|
||||
}
|
||||
if (!party.getAgent().getId().equals(agent.getId())) {
|
||||
throw new PartyNotCollectedByAgentException(partyCode, agent.getAgentCode());
|
||||
}
|
||||
return party;
|
||||
}
|
||||
|
||||
private String stringOf(final JsonObject json, final String field) {
|
||||
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.starter;
|
||||
|
||||
import org.apache.fineract.organisation.agent.domain.AgentRepository;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
|
||||
import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntryRepository;
|
||||
import org.apache.fineract.portfolio.dds.collection.serialization.CollectionEntryCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryReadPlatformService;
|
||||
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryWritePlatformService;
|
||||
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryWritePlatformServiceJpaRepositoryImpl;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
|
||||
import org.apache.fineract.portfolio.dds.transaction.service.PartyTransactionWritePlatformService;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
@Configuration
|
||||
public class DdsCollectionConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(CollectionEntryReadPlatformService.class)
|
||||
public CollectionEntryReadPlatformService collectionEntryReadPlatformService(JdbcTemplate jdbcTemplate,
|
||||
AgentRepository agentRepository) {
|
||||
return new CollectionEntryReadPlatformServiceImpl(jdbcTemplate, agentRepository);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(CollectionEntryWritePlatformService.class)
|
||||
public CollectionEntryWritePlatformService collectionEntryWritePlatformService(
|
||||
CollectionEntryCommandFromApiJsonDeserializer fromApiJsonDeserializer, CollectionEntryRepository collectionEntryRepository,
|
||||
AgentRepository agentRepository, PartyRepository partyRepository,
|
||||
PartyTransactionWritePlatformService partyTransactionWritePlatformService) {
|
||||
return new CollectionEntryWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, collectionEntryRepository,
|
||||
agentRepository, partyRepository, partyTransactionWritePlatformService);
|
||||
}
|
||||
}
|
||||
+86
-8
@@ -31,6 +31,7 @@ import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
@@ -40,6 +41,7 @@ import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamE
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.dds.party.data.PartyData;
|
||||
import org.apache.fineract.portfolio.dds.party.service.PartyReadPlatformService;
|
||||
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/dds/parties")
|
||||
@@ -54,13 +56,53 @@ public class PartiesApiResource {
|
||||
private final PartyReadPlatformService readPlatformService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
/**
|
||||
* Called directly for the one-off deposit-account catch-up. That is a data fix rather than an
|
||||
* everyday action, so it takes no request body and does not need the command-source machinery -
|
||||
* the account it opens and the entries it writes are their own record.
|
||||
*/
|
||||
private final PartyWritePlatformService partyWritePlatformService;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve DDS Parties", description = "Returns the list of DDS parties.")
|
||||
public Collection<PartyData> retrieveAll() {
|
||||
@Operation(summary = "Retrieve DDS Parties", description = "Returns the list of DDS parties. "
|
||||
+ "Pass agentId to get only the parties a given agent collects for - what the daily "
|
||||
+ "transaction screen needs, since it works one agent at a time.")
|
||||
public Collection<PartyData> retrieveAll(
|
||||
@QueryParam("agentId") @Parameter(description = "agentId") final Long agentId) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
return readPlatformService.retrieveAll();
|
||||
return readPlatformService.retrieveAll(agentId);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("dueforclosure")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "DDS accounts due for closure", description = "The accounts whose term has run out and which are still "
|
||||
+ "open - the branch's list of closures waiting to be done, oldest maturity first.\n" + "\n"
|
||||
+ "Nothing closes itself. A matured account is money the bank now owes the customer, and where it goes has to be "
|
||||
+ "agreed with them, so it stays on this list until a clerk deals with it - in two steps, paying it out and then "
|
||||
+ "closing it. An account still carrying a balance is waiting on the payment; one showing nil has been paid and "
|
||||
+ "is waiting on the close.")
|
||||
public Collection<PartyData> retrieveDueForClosure() {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
return readPlatformService.retrieveDueForClosure();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("creditaccounts")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Accounts a DDS maturity may be paid into",
|
||||
description = "The customer's own accounts that can take a maturity payout: active, a savings account rather "
|
||||
+ "than a fixed or recurring deposit, and not one of their DDS scheme accounts.\n" + "\n"
|
||||
+ "The same conditions command=payout enforces, so a screen offering this list cannot offer a choice "
|
||||
+ "the server will then refuse. Returns id, accountNo, productName and balance.")
|
||||
public Collection<Map<String, Object>> retrieveCreditableAccounts(
|
||||
@QueryParam("clientId") @Parameter(description = "clientId") final Long clientId) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
return readPlatformService.retrieveCreditableAccounts(clientId);
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -79,11 +121,13 @@ public class PartiesApiResource {
|
||||
@Operation(summary = "Create a DDS Party", description = "Mandatory fields: clientId, agentId, subType, partyName, address1, "
|
||||
+ "city, pincode, mobileNoSms, operation, adultMinor, openingDate, dailyDepositAmount, days\n" + "\n"
|
||||
+ "Optional fields: lastAccount, nominal, aadharNo, partyName1, address2, address3, gender, panCardNo, telephone, "
|
||||
+ "birthDate, occupation, risk, ckycId, minorDate, guardianClientId, minAccountOpt, relation, month, maturityDate, "
|
||||
+ "birthDate, occupation, risk, ckycId, minorDate, guardianClientId, minAccountOpt, relation, month, "
|
||||
+ "reopenDate, depositAmount, ledgerNo, withdrawType, lien, payDate, paymentAmount, openingBalance, isSmsActive, "
|
||||
+ "isEmailActive, interestDate, spDescription, freezeCode, advCode, creditToBranch, creditToAccountType, "
|
||||
+ "creditToAccount, nomineeApplicable, nomineeSplitMode, nominees, savingsAccountNo (links this party to an existing, "
|
||||
+ "non-closed real savings account of the same client, resolved by account number - omit if the client has none)")
|
||||
+ "non-closed real savings account of the same client, resolved by account number - omit if the client has none)\n" + "\n"
|
||||
+ "maturityDate and maturityAmount are not accepted: both are worked out from openingDate, month/days, "
|
||||
+ "dailyDepositAmount and interestRate, and are returned on read.")
|
||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.createParty() //
|
||||
@@ -110,12 +154,46 @@ public class PartiesApiResource {
|
||||
@Path("{partyId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Close a DDS Party", description = "command=close is the only supported command. Mandatory field: "
|
||||
+ "closeDate. The party's balance must already be zero (settled with a full Withdraw Deposite payout) before it "
|
||||
+ "can be closed.")
|
||||
@Operation(summary = "Act on a DDS Party", description = "command=close closes the party - its balance must already be "
|
||||
+ "zero (settled with command=payout, or with a full Withdraw Deposite) and closeDate is mandatory.\n" + "\n"
|
||||
+ "command=opendepositaccount opens the real deposit account of a party created before DDS began opening them, "
|
||||
+ "and brings its existing balance onto that account. Run once per such party; refused for a party that already "
|
||||
+ "has an account. No request body.\n" + "\n"
|
||||
+ "command=payout pays a matured account out and leaves it open with a nil balance, for command=close to be run "
|
||||
+ "against separately once the clerk has seen the money land. What is paid is what the account actually holds - "
|
||||
+ "the deposits plus the interest already posted - not the figure quoted at opening. Optional field: "
|
||||
+ "toSavingsAccountNo, another open savings account of the same customer, which the money is moved into. Omit it "
|
||||
+ "when the customer has no other account here: the money is then parked in the matured-deposits payable account "
|
||||
+ "for the branch to send on to their account at another bank. Refused before the maturity date, and refused on "
|
||||
+ "an account with nothing left to pay - that one needs closing, not paying.\n" + "\n"
|
||||
+ "command=prematureclose closes an account before its term is up and pays out what is left, in one act - "
|
||||
+ "unlike payout, which leaves the account open. Mandatory field: shortfallBorneBy, either CUSTOMER (their "
|
||||
+ "interest is recalculated at the scheme's reduced rate and the difference taken back) or AGENT (the customer "
|
||||
+ "keeps a full month's interest and the agent funds the days left in it). The agent is charged a share of "
|
||||
+ "their commission either way. Optional field: toSavingsAccountNo, as for payout. The lock-in period, the "
|
||||
+ "rate reduction and the agent's share are the scheme's own and are read as the account is closed, so terms "
|
||||
+ "the bank changes take effect on accounts already open. Refused inside the lock-in period, and refused on an "
|
||||
+ "account that has already matured - that one is paid out and closed in the ordinary way.")
|
||||
public CommandProcessingResult handleCommand(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId,
|
||||
@QueryParam("command") @Parameter(description = "command") final String commandParam,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
if ("opendepositaccount".equals(commandParam)) {
|
||||
return this.partyWritePlatformService.openDepositAccount(partyId);
|
||||
}
|
||||
if ("payout".equals(commandParam)) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.payOutParty(partyId) //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
if ("prematureclose".equals(commandParam)) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.prematureCloseParty(partyId) //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
if (!"close".equals(commandParam)) {
|
||||
throw new UnrecognizedQueryParamException("command", commandParam);
|
||||
}
|
||||
|
||||
+92
-4
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.domain;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
@@ -26,17 +27,104 @@ import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface PartyRepository extends JpaRepository<Party, Long> {
|
||||
|
||||
@Query("select coalesce(max(cast(substring(p.partyCode, 3) as long)), 0) from Party p where p.partyCode like 'PT%'")
|
||||
/**
|
||||
* Native rather than JPQL: {@code cast(... as long)} reaches PostgreSQL verbatim, which knows no
|
||||
* "long" type - the cast has to name a database type.
|
||||
*/
|
||||
@Query(value = "select coalesce(max(cast(substring(party_code, 3) as bigint)), 0) from m_dds_party "
|
||||
+ "where party_code like 'PT%'", nativeQuery = true)
|
||||
Long retrieveMaxPartyCodeSuffix();
|
||||
|
||||
Optional<Party> findByPartyCode(String partyCode);
|
||||
|
||||
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo")
|
||||
/**
|
||||
* Open accounts still assigned to this agent. Nobody collects for an agent who has been switched
|
||||
* off, so these would simply stop being collected from without anyone being told.
|
||||
*/
|
||||
@Query("select count(p) from Party p where p.agent.id = :agentId and p.status = 'ACTIVE'")
|
||||
Long countActivePartiesOfAgent(@Param("agentId") Long agentId);
|
||||
|
||||
/**
|
||||
* Accounts an interest run covers. Closed accounts are excluded: a closed account has been paid
|
||||
* out and settled to zero, so accruing further interest on it would credit an account nobody can
|
||||
* draw on and put the DDS balances out of step with what was actually paid.
|
||||
*/
|
||||
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
|
||||
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType)")
|
||||
List<Party> findForInterestBatch(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
|
||||
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo);
|
||||
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType);
|
||||
|
||||
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
|
||||
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) "
|
||||
+ "and (p.od1Flag = true or p.od2Flag = true)")
|
||||
List<Party> findForOdInterestBatch(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
|
||||
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo);
|
||||
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType);
|
||||
|
||||
/**
|
||||
* Accounts holding interest that has been calculated but not yet credited - what an Interest
|
||||
* Posting run has to pay in. Accounts holding nothing are left out rather than previewed as zero
|
||||
* rows.
|
||||
*/
|
||||
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
|
||||
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) and p.interestCalc > 0")
|
||||
List<Party> findWithAccruedInterest(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
|
||||
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType);
|
||||
|
||||
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
|
||||
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) and p.odInterestCalc > 0")
|
||||
List<Party> findWithAccruedOdInterest(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
|
||||
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType);
|
||||
|
||||
/**
|
||||
* Matured accounts a Maturity Auto Close run could settle: the term is up, there is money to hand
|
||||
* back, and the customer has already said which account to hand it into.
|
||||
*
|
||||
* The destination is part of the search rather than a check made afterwards, because it is what
|
||||
* separates an account this run may touch from one it may not. Without it there is nowhere to
|
||||
* send the money, and the run's whole premise - that nobody need choose anything today, because
|
||||
* the choice was made when the account was opened - does not hold.
|
||||
*
|
||||
* Two fields carry that choice and both are asked for. An account number on its own is a note of
|
||||
* where the customer banks; the Adv Code is the instruction to use it, and only "Transfer To
|
||||
* Saving" says the matured amount goes there. Reading the number without the code would settle
|
||||
* accounts whose customer never asked for it - fourteen of the fifteen on the books say "None".
|
||||
*
|
||||
* Whether the scheme has auto-closing switched on is decided by the caller, which holds the
|
||||
* schemes; this asks only about the accounts. Ordered oldest maturity first, so the ones that
|
||||
* have waited longest are listed at the top of what the clerk reviews.
|
||||
*/
|
||||
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
|
||||
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) "
|
||||
+ "and p.maturityDate is not null and p.maturityDate <= :asOn and p.currentBalance > 0 "
|
||||
+ "and lower(p.advCode) = 'transfer to saving' "
|
||||
+ "and p.creditToAccount is not null and length(trim(p.creditToAccount)) > 0 "
|
||||
+ "order by p.maturityDate, p.id")
|
||||
List<Party> findDueForMaturityAutoClose(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
|
||||
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType,
|
||||
@Param("asOn") LocalDate asOn);
|
||||
|
||||
/**
|
||||
* Matured accounts whose interest has not been brought up to their maturity date.
|
||||
*
|
||||
* Interest reaches an account through a posting run, and a run is a monthly thing, set per
|
||||
* scheme and switchable off. A term ending between two runs therefore leaves the last stretch
|
||||
* uncredited, and a payout made before the next run hands the customer less than they earned -
|
||||
* which is what happened to every account on a scheme with automatic posting turned off.
|
||||
*
|
||||
* Maturity is the one date the money cannot wait for the next run, so these are found on their
|
||||
* own and brought up to date. Anything already posted through its maturity date is left alone.
|
||||
*/
|
||||
@Query("select p from Party p where p.status = 'ACTIVE' and p.maturityDate is not null and p.maturityDate <= :asOn "
|
||||
+ "and (p.interestDate is null or p.interestDate < p.maturityDate) order by p.maturityDate, p.id")
|
||||
List<Party> findMaturedWithInterestOutstanding(@Param("asOn") LocalDate asOn);
|
||||
|
||||
/**
|
||||
* Minors' accounts whose holder has reached the age of majority.
|
||||
*
|
||||
* Found on their birthday and not before, but turned over only once their own KYC is verified -
|
||||
* the caller checks that, because the answer lives on the bank's KYC record rather than here.
|
||||
*/
|
||||
@Query("select p from Party p where p.status = 'ACTIVE' and lower(p.adultMinor) = 'minor' "
|
||||
+ "and p.birthDate is not null and p.birthDate <= :bornOnOrBefore order by p.id")
|
||||
List<Party> findMinorsWhoHaveComeOfAge(@Param("bornOnOrBefore") LocalDate bornOnOrBefore);
|
||||
}
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a DDS party cannot be opened because no savings product has been named for DDS.
|
||||
*
|
||||
* Refusing is the point. A party created without a real account behind it is the state this whole
|
||||
* change exists to end - the customer's money would live only inside DDS again, and nobody would
|
||||
* notice until someone asked the bank's books where it was.
|
||||
*/
|
||||
public class DdsDepositProductNotConfiguredException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public DdsDepositProductNotConfiguredException() {
|
||||
super("error.msg.dds.deposit.product.not.configured",
|
||||
"No savings product is set for DDS accounts, so this customer's account cannot be opened. "
|
||||
+ "Set the 'dds-deposit-product' configuration to a savings product first.");
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a party that already has a deposit account is asked to open one.
|
||||
*
|
||||
* Two accounts for one customer is worse than the one they have: collections would start splitting
|
||||
* between them, and the opening balance would be counted twice. The catch-up is meant to be run once
|
||||
* per party, and running it twice is the mistake this catches.
|
||||
*/
|
||||
public class PartyAccountAlreadyOpenException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyAccountAlreadyOpenException(final String partyCode) {
|
||||
super("error.msg.dds.party.account.already.open",
|
||||
"Party " + partyCode + " already has a deposit account, so another one cannot be opened for it.", partyCode);
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when an account that has already reached its term is put through an early closure.
|
||||
*
|
||||
* Nothing was closed early, so there is no shortfall to settle: the customer saved for the whole term
|
||||
* and has earned the interest in full. Charging for an early closure here would take money they are
|
||||
* owed, which is why this is refused rather than quietly charging nothing - the clerk has picked the
|
||||
* wrong action, and should pay the account out and close it in the ordinary way.
|
||||
*/
|
||||
public class PartyAlreadyMaturedException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyAlreadyMaturedException(final String partyCode, final LocalDate maturityDate) {
|
||||
super("error.msg.dds.party.already.matured",
|
||||
"Account " + partyCode + " matured on " + maturityDate
|
||||
+ ", so it cannot be closed early. Pay it out and close it in the ordinary way.",
|
||||
partyCode, maturityDate);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a party's maturity proceeds are pointed at an account that cannot receive them.
|
||||
*
|
||||
* The whole matured amount goes to this account, years after somebody typed it. Refusing it now puts
|
||||
* the problem in front of the officer who can still ask the customer; the alternative is finding out
|
||||
* on the maturity date, with the money owed and nowhere to send it.
|
||||
*/
|
||||
public class PartyCreditAccountUnusableException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyCreditAccountUnusableException(final String accountNo, final String reason) {
|
||||
super("error.msg.dds.party.credit.account.unusable", "The maturity proceeds cannot be credited to account " + accountNo + ": "
|
||||
+ reason + ". Pick one of this customer's own open savings accounts.", accountNo, reason);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a maturity payout is attempted on a party that has no real deposit account behind it.
|
||||
*
|
||||
* There is nowhere to pay the money out of. Parties opened before DDS began opening real accounts
|
||||
* are in this state; the deposit-account catch-up brings them onto the books, and only then can they
|
||||
* be paid out and closed.
|
||||
*/
|
||||
public class PartyHasNoDepositAccountException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyHasNoDepositAccountException(final String partyCode) {
|
||||
super("error.msg.dds.party.has.no.deposit.account", "Account " + partyCode
|
||||
+ " has no deposit account behind it, so there is nothing to pay out from. Open its deposit account first.", partyCode);
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a matured account is put through a payout while money on it is pledged against a loan.
|
||||
*
|
||||
* A customer who needs cash before their term ends can borrow against the deposit instead of breaking
|
||||
* it: the scheme carries on, and the amount lent is marked as a lien so it cannot leave. Paying the
|
||||
* deposit out while that stands would hand the customer money the bank is holding as its security and
|
||||
* leave the loan with nothing behind it.
|
||||
*
|
||||
* So maturity waits on the loan. The term has ended and the account is owed, but it is not paid until
|
||||
* the borrowing against it is cleared and the lien released.
|
||||
*/
|
||||
public class PartyHeldUnderLienException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyHeldUnderLienException(final String partyCode, final BigDecimal heldAmount) {
|
||||
super("error.msg.dds.party.held.under.lien",
|
||||
"Account " + partyCode + " has " + inRupees(heldAmount) + " held under lien against a loan. "
|
||||
+ "The loan must be repaid and the lien released before this deposit can be paid out.",
|
||||
partyCode, heldAmount);
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole account is held, by the lien marked on the party itself.
|
||||
*
|
||||
* No amount, because there is none to state: DDS pays a deposit out whole or not at all, so a
|
||||
* lien on part of one would hold nothing the customer could otherwise have reached. The reason
|
||||
* takes its place, and is what the clerk needs - "held" they can already see, "held for what"
|
||||
* is the thing nobody could answer before.
|
||||
*/
|
||||
public PartyHeldUnderLienException(final String partyCode, final String reason) {
|
||||
super("error.msg.dds.party.held.under.lien",
|
||||
"Account " + partyCode + " is under lien" + because(reason)
|
||||
+ ". The lien must be released before money can leave this account.",
|
||||
partyCode, reason);
|
||||
}
|
||||
|
||||
private static String because(final String reason) {
|
||||
return reason == null || reason.isBlank() ? "" : " (" + reason.trim() + ")";
|
||||
}
|
||||
|
||||
/**
|
||||
* Balances are carried to six places, which is right for arithmetic and wrong for a sentence - a
|
||||
* clerk reading "500.000000" sees a system message, not an amount of money.
|
||||
*/
|
||||
private static String inRupees(final BigDecimal amount) {
|
||||
return amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user