Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0e3c0ac8b | ||
|
|
fa8e5f3f16 | ||
|
|
12fdf035ca | ||
|
|
37c961f713 |
+13
-1
@@ -164,12 +164,24 @@ public class CommandSourceService {
|
|||||||
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
|
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| isDdsEntity(commandSource.getEntityName())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The DDS module's maker-checker entities. A super user's DDS work goes to a checker like anyone
|
||||||
|
* else's: DDS moves customers' money, and every such move is to be approved by a second person.
|
||||||
|
*/
|
||||||
|
public static boolean isDdsEntity(final String entityName) {
|
||||||
|
return entityName != null && switch (entityName.toUpperCase()) {
|
||||||
|
case "PARTY", "AGENT", "PARTYTRANSACTION", "DDSACCOUNTTYPE", "COMMISSIONSLAB", "DDSBATCH", "DDSCOLLECTIONENTRY" -> true;
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
||||||
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+161
@@ -664,6 +664,167 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder createAgent() {
|
||||||
|
this.actionName = "CREATE";
|
||||||
|
this.entityName = "AGENT";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/dds/agents/template";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder updateAgent(final Long agentId) {
|
||||||
|
this.actionName = "UPDATE";
|
||||||
|
this.entityName = "AGENT";
|
||||||
|
this.entityId = agentId;
|
||||||
|
this.href = "/dds/agents/" + agentId;
|
||||||
|
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";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/dds/parties/template";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder updateParty(final Long partyId) {
|
||||||
|
this.actionName = "UPDATE";
|
||||||
|
this.entityName = "PARTY";
|
||||||
|
this.entityId = partyId;
|
||||||
|
this.href = "/dds/parties/" + partyId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder closeParty(final Long partyId) {
|
||||||
|
this.actionName = "CLOSE";
|
||||||
|
this.entityName = "PARTY";
|
||||||
|
this.entityId = partyId;
|
||||||
|
this.href = "/dds/parties/" + partyId + "?command=close";
|
||||||
|
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 prematureCloseOverrideParty(final Long partyId) {
|
||||||
|
this.actionName = "PREMATURECLOSEOVERRIDE";
|
||||||
|
this.entityName = "PARTY";
|
||||||
|
this.entityId = partyId;
|
||||||
|
this.href = "/dds/parties/" + partyId + "?command=prematurecloseoverride";
|
||||||
|
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";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/dds/partytransactions/template";
|
||||||
|
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";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/dds/commissionslabs/template";
|
||||||
|
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";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/dds/batchruns/template";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder postDdsBatchRun(final Long batchRunId) {
|
||||||
|
this.actionName = "POST";
|
||||||
|
this.entityName = "DDSBATCH";
|
||||||
|
this.entityId = batchRunId;
|
||||||
|
this.href = "/dds/batchruns/" + batchRunId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public CommandWrapperBuilder deleteOffice(final Long officeId) {
|
public CommandWrapperBuilder deleteOffice(final Long officeId) {
|
||||||
this.actionName = "DELETE";
|
this.actionName = "DELETE";
|
||||||
this.entityName = "OFFICE";
|
this.entityName = "OFFICE";
|
||||||
|
|||||||
+3
-1
@@ -123,7 +123,9 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
|
|||||||
AppUser appUser = this.context.authenticatedUser();
|
AppUser appUser = this.context.authenticatedUser();
|
||||||
String permissionCode = commandSource.getPermissionCode();
|
String permissionCode = commandSource.getPermissionCode();
|
||||||
appUser.validateHasCheckerPermissionTo(permissionCode);
|
appUser.validateHasCheckerPermissionTo(permissionCode);
|
||||||
if (!configurationService.isSameMakerCheckerEnabled() && !appUser.isCheckerSuperUser()) {
|
// A DDS entry is never checked by the user who made it, super user or not - see CommandSourceService.isDdsEntity.
|
||||||
|
if (!configurationService.isSameMakerCheckerEnabled()
|
||||||
|
&& (!appUser.isCheckerSuperUser() || CommandSourceService.isDdsEntity(commandSource.getEntityName()))) {
|
||||||
AppUser maker = commandSource.getMaker();
|
AppUser maker = commandSource.getMaker();
|
||||||
if (maker == null) {
|
if (maker == null) {
|
||||||
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
|
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
|
||||||
|
|||||||
+1
@@ -72,6 +72,7 @@ public enum JobName {
|
|||||||
OUTWARD_CLEARING_BATCH_SEND("CTS Outward Clearing Batch Send"), //
|
OUTWARD_CLEARING_BATCH_SEND("CTS Outward Clearing Batch Send"), //
|
||||||
CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), //
|
CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), //
|
||||||
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
|
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
|
||||||
|
POST_DDS_INTEREST("Post DDS Interest"), //
|
||||||
CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), //
|
CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), //
|
||||||
; //
|
; //
|
||||||
|
|
||||||
|
|||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* 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.data;
|
||||||
|
|
||||||
|
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 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
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final Long clientId;
|
||||||
|
private final String clientName;
|
||||||
|
private final String agentCode;
|
||||||
|
private final String agentNameId;
|
||||||
|
private final String agentAddress1;
|
||||||
|
private final String agentAddress2;
|
||||||
|
private final String agentCity;
|
||||||
|
private final String agentPinCode;
|
||||||
|
private final String agentTelephone;
|
||||||
|
private final String agentSavingAccountType;
|
||||||
|
private final String agentSavingAccountNo;
|
||||||
|
private final LocalDate agentOpeningDate;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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 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;
|
||||||
|
}
|
||||||
+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.organisation.agent.data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable data object representing a single agent guarantor.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public final class AgentGuarantorData implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final Long clientId;
|
||||||
|
private final String guarantorName;
|
||||||
|
private final String address1;
|
||||||
|
private final String address2;
|
||||||
|
private final String city;
|
||||||
|
private final String pinCode;
|
||||||
|
private final String telephone;
|
||||||
|
|
||||||
|
public static AgentGuarantorData instance(final Long id, final Long clientId, final String guarantorName, final String address1,
|
||||||
|
final String address2, final String city, final String pinCode, final String telephone) {
|
||||||
|
return new AgentGuarantorData(id, clientId, guarantorName, address1, address2, city, pinCode, telephone);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentGuarantorData(final Long id, final Long clientId, final String guarantorName, final String address1,
|
||||||
|
final String address2, final String city, final String pinCode, final String telephone) {
|
||||||
|
this.id = id;
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.guarantorName = guarantorName;
|
||||||
|
this.address1 = address1;
|
||||||
|
this.address2 = address2;
|
||||||
|
this.city = city;
|
||||||
|
this.pinCode = pinCode;
|
||||||
|
this.telephone = telephone;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
/**
|
||||||
|
* 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.CascadeType;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.OrderBy;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.UniqueConstraint;
|
||||||
|
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 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.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 AbstractAuditableCustom {
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "client_id", nullable = false)
|
||||||
|
private Client client;
|
||||||
|
|
||||||
|
@Column(name = "agent_code", length = 20, nullable = false)
|
||||||
|
private String agentCode;
|
||||||
|
|
||||||
|
@Column(name = "agent_name", length = 100, nullable = false)
|
||||||
|
private String agentName;
|
||||||
|
|
||||||
|
@Column(name = "agent_address1", length = 200, nullable = false)
|
||||||
|
private String agentAddress1;
|
||||||
|
|
||||||
|
@Column(name = "agent_address2", length = 200)
|
||||||
|
private String agentAddress2;
|
||||||
|
|
||||||
|
@Column(name = "agent_city", length = 100, nullable = false)
|
||||||
|
private String agentCity;
|
||||||
|
|
||||||
|
@Column(name = "agent_pincode", length = 20, nullable = false)
|
||||||
|
private String agentPinCode;
|
||||||
|
|
||||||
|
@Column(name = "agent_telephone", length = 20)
|
||||||
|
private String agentTelephone;
|
||||||
|
|
||||||
|
@Column(name = "agent_saving_account_type", length = 100, nullable = false)
|
||||||
|
private String agentSavingAccountType;
|
||||||
|
|
||||||
|
@Column(name = "agent_saving_account_no", length = 50, nullable = false)
|
||||||
|
private String agentSavingAccountNo;
|
||||||
|
|
||||||
|
@Column(name = "agent_opening_date", nullable = false)
|
||||||
|
private LocalDate agentOpeningDate;
|
||||||
|
|
||||||
|
@Column(name = "agent_birth_date", nullable = false)
|
||||||
|
private LocalDate agentBirthDate;
|
||||||
|
|
||||||
|
@Column(name = "agent_current_balance", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal agentCurrentBalance;
|
||||||
|
|
||||||
|
@Column(name = "agent_opening_balance", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal agentOpeningBalance;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
|
||||||
|
@Column(name = "festival_advance", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal festivalAdvance;
|
||||||
|
|
||||||
|
@Column(name = "other_deduction", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal otherDeduction;
|
||||||
|
|
||||||
|
@Column(name = "last_commission_date")
|
||||||
|
private LocalDate lastCommissionDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<AgentGuarantor> guarantors = new ArrayList<>();
|
||||||
|
|
||||||
|
protected Agent() {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Office office) {
|
||||||
|
this.client = client;
|
||||||
|
this.agentCode = agentCode;
|
||||||
|
this.agentName = agentName;
|
||||||
|
this.agentAddress1 = agentAddress1;
|
||||||
|
this.agentAddress2 = StringUtils.defaultIfEmpty(agentAddress2, null);
|
||||||
|
this.agentCity = agentCity;
|
||||||
|
this.agentPinCode = agentPinCode;
|
||||||
|
this.agentTelephone = StringUtils.defaultIfEmpty(agentTelephone, null);
|
||||||
|
this.agentSavingAccountType = agentSavingAccountType;
|
||||||
|
this.agentSavingAccountNo = agentSavingAccountNo;
|
||||||
|
this.agentOpeningDate = agentOpeningDate;
|
||||||
|
this.agentBirthDate = agentBirthDate;
|
||||||
|
this.agentOpeningBalance = agentOpeningBalance == null ? BigDecimal.ZERO : agentOpeningBalance;
|
||||||
|
this.agentCurrentBalance = this.agentOpeningBalance;
|
||||||
|
setOffice(office);
|
||||||
|
this.active = true;
|
||||||
|
|
||||||
|
this.commission = BigDecimal.ZERO;
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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");
|
||||||
|
final String agentCity = command.stringValueOfParameterNamed("agentCity");
|
||||||
|
final String agentPinCode = command.stringValueOfParameterNamed("agentPinCode");
|
||||||
|
final String agentTelephone = command.stringValueOfParameterNamedAllowingNull("agentTelephone");
|
||||||
|
final String agentSavingAccountType = command.stringValueOfParameterNamed("agentSavingAccountType");
|
||||||
|
final String agentSavingAccountNo = command.stringValueOfParameterNamed("agentSavingAccountNo");
|
||||||
|
final LocalDate agentOpeningDate = command.localDateValueOfParameterNamed("agentOpeningDate");
|
||||||
|
final LocalDate agentBirthDate = command.localDateValueOfParameterNamed("agentBirthDate");
|
||||||
|
final BigDecimal agentOpeningBalance = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("agentOpeningBalance");
|
||||||
|
|
||||||
|
return new Agent(client, agentCode, agentName, agentAddress1, agentAddress2, agentCity, agentPinCode, agentTelephone,
|
||||||
|
agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate, agentOpeningBalance, office);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addGuarantor(final AgentGuarantor guarantor) {
|
||||||
|
guarantor.setAgent(this);
|
||||||
|
this.guarantors.add(guarantor);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void resetGuarantors() {
|
||||||
|
this.guarantors.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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.
|
||||||
|
*/
|
||||||
|
public BigDecimal credit(final BigDecimal amount) {
|
||||||
|
this.agentCurrentBalance = this.agentCurrentBalance.add(amount);
|
||||||
|
return this.agentCurrentBalance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal settle(final BigDecimal amount) {
|
||||||
|
this.agentCurrentBalance = this.agentCurrentBalance.subtract(amount);
|
||||||
|
return this.agentCurrentBalance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void changeClient(final Client newClient) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> update(final JsonCommand command) {
|
||||||
|
|
||||||
|
final Map<String, Object> actualChanges = new LinkedHashMap<>(10);
|
||||||
|
|
||||||
|
if (command.isChangeInLongParameterNamed("clientId", this.client.getId())) {
|
||||||
|
actualChanges.put("clientId", command.longValueOfParameterNamed("clientId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed("agentNameId", this.agentName)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed("agentNameId");
|
||||||
|
actualChanges.put("agentNameId", newValue);
|
||||||
|
this.agentName = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed("agentAddress1", this.agentAddress1)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed("agentAddress1");
|
||||||
|
actualChanges.put("agentAddress1", newValue);
|
||||||
|
this.agentAddress1 = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed("agentAddress2", this.agentAddress2)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed("agentAddress2");
|
||||||
|
actualChanges.put("agentAddress2", newValue);
|
||||||
|
this.agentAddress2 = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed("agentCity", this.agentCity)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed("agentCity");
|
||||||
|
actualChanges.put("agentCity", newValue);
|
||||||
|
this.agentCity = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed("agentPinCode", this.agentPinCode)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed("agentPinCode");
|
||||||
|
actualChanges.put("agentPinCode", newValue);
|
||||||
|
this.agentPinCode = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed("agentTelephone", this.agentTelephone)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed("agentTelephone");
|
||||||
|
actualChanges.put("agentTelephone", newValue);
|
||||||
|
this.agentTelephone = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed("agentSavingAccountType", this.agentSavingAccountType)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed("agentSavingAccountType");
|
||||||
|
actualChanges.put("agentSavingAccountType", newValue);
|
||||||
|
this.agentSavingAccountType = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed("agentSavingAccountNo", this.agentSavingAccountNo)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed("agentSavingAccountNo");
|
||||||
|
actualChanges.put("agentSavingAccountNo", newValue);
|
||||||
|
this.agentSavingAccountNo = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInDateParameterNamed("agentOpeningDate", this.agentOpeningDate)) {
|
||||||
|
actualChanges.put("agentOpeningDate", command.stringValueOfParameterNamed("agentOpeningDate"));
|
||||||
|
this.agentOpeningDate = command.localDateValueOfParameterNamed("agentOpeningDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInDateParameterNamed("agentBirthDate", this.agentBirthDate)) {
|
||||||
|
actualChanges.put("agentBirthDate", command.stringValueOfParameterNamed("agentBirthDate"));
|
||||||
|
this.agentBirthDate = command.localDateValueOfParameterNamed("agentBirthDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
actualChanges.put("tdsApplicable", newValue);
|
||||||
|
this.tdsApplicable = 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);
|
||||||
|
|
||||||
|
// 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 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 == null ? BigDecimal.ZERO : newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* 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 lombok.Getter;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
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 AbstractAuditableCustom {
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "agent_id", nullable = false)
|
||||||
|
private Agent agent;
|
||||||
|
|
||||||
|
@Column(name = "client_id", nullable = false)
|
||||||
|
private Long clientId;
|
||||||
|
|
||||||
|
@Column(name = "guarantor_name", length = 100, nullable = false)
|
||||||
|
private String guarantorName;
|
||||||
|
|
||||||
|
@Column(name = "address1", length = 200, nullable = false)
|
||||||
|
private String address1;
|
||||||
|
|
||||||
|
@Column(name = "address2", length = 200)
|
||||||
|
private String address2;
|
||||||
|
|
||||||
|
@Column(name = "city", length = 100, nullable = false)
|
||||||
|
private String city;
|
||||||
|
|
||||||
|
@Column(name = "pin_code", length = 20, nullable = false)
|
||||||
|
private String pinCode;
|
||||||
|
|
||||||
|
@Column(name = "telephone", length = 20)
|
||||||
|
private String telephone;
|
||||||
|
|
||||||
|
protected AgentGuarantor() {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public AgentGuarantor(final Long clientId, final String guarantorName, final String address1, final String address2,
|
||||||
|
final String city, final String pinCode, final String telephone) {
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.guarantorName = guarantorName;
|
||||||
|
this.address1 = address1;
|
||||||
|
this.address2 = StringUtils.defaultIfEmpty(address2, null);
|
||||||
|
this.city = city;
|
||||||
|
this.pinCode = pinCode;
|
||||||
|
this.telephone = StringUtils.defaultIfEmpty(telephone, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.organisation.agent.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||||
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link RuntimeException} thrown when a DDS agent resource is not found.
|
||||||
|
*/
|
||||||
|
public class AgentNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||||
|
|
||||||
|
public AgentNotFoundException(final Long id) {
|
||||||
|
super("error.msg.agent.id.invalid", "Agent with identifier " + id + " does not exist", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AgentNotFoundException(final Long id, final EmptyResultDataAccessException e) {
|
||||||
|
super("error.msg.agent.id.invalid", "Agent with identifier " + id + " does not exist", id, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AgentNotFoundException(final String agentCode) {
|
||||||
|
super("error.msg.agent.code.invalid", "Agent with code " + agentCode + " does not exist", agentCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.commissionslab.data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
public final class CommissionSlabData implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final String slabType;
|
||||||
|
private final BigDecimal slabFrom;
|
||||||
|
private final BigDecimal slabTo;
|
||||||
|
private final BigDecimal commissionPercent;
|
||||||
|
}
|
||||||
+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.organisation.commissionslab.domain;
|
||||||
|
|
||||||
|
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.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).
|
||||||
|
* 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 AbstractAuditableCustom {
|
||||||
|
|
||||||
|
@Column(name = "slab_type", length = 20, nullable = false)
|
||||||
|
private String slabType;
|
||||||
|
|
||||||
|
@Column(name = "slab_from", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal slabFrom;
|
||||||
|
|
||||||
|
@Column(name = "slab_to", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal slabTo;
|
||||||
|
|
||||||
|
@Column(name = "commission_percent", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal commissionPercent;
|
||||||
|
|
||||||
|
protected CommissionSlab() {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommissionSlab(final String slabType, final BigDecimal slabFrom, final BigDecimal slabTo, final BigDecimal commissionPercent) {
|
||||||
|
this.slabType = slabType;
|
||||||
|
this.slabFrom = slabFrom;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.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;
|
||||||
|
/** The scheme's annual interest rate, which an account opened on it takes. */
|
||||||
|
private final BigDecimal interestRate;
|
||||||
|
private final Boolean active;
|
||||||
|
private final LocalDateTime createdDate;
|
||||||
|
private final LocalDateTime lastModifiedDate;
|
||||||
|
}
|
||||||
+267
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scheme's annual interest rate. Mandatory: an account opened on the scheme takes this rate,
|
||||||
|
* rather than one typed at the counter for each customer.
|
||||||
|
*/
|
||||||
|
@Column(name = "interest_rate", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal interestRate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
this.interestRate = command.bigDecimalValueOfParameterNamed("interestRate");
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The scheme's rate - every open account on it follows it (see the account type service).
|
||||||
|
if (command.parameterExists("interestRate") && command.isChangeInBigDecimalParameterNamed("interestRate", this.interestRate)) {
|
||||||
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("interestRate");
|
||||||
|
actualChanges.put("interestRate", newValue);
|
||||||
|
this.interestRate = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* 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.data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Collection;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder(toBuilder = true)
|
||||||
|
public final class BatchRunData implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
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;
|
||||||
|
private final Long partyFrom;
|
||||||
|
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;
|
||||||
|
private final Collection<BatchRunRowData> rows;
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* 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.data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
public final class BatchRunRowData implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final Long partyId;
|
||||||
|
private final String partyCode;
|
||||||
|
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;
|
||||||
|
}
|
||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* 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.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.CascadeType;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.OrderBy;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Calculate-then-Post batch run - the deliberate two-step control from the legacy Interest Part
|
||||||
|
* menu (Interest Calculation -> Interest Posting, OD Interest Calculation -> OD Interest Posting,
|
||||||
|
* Commission Calculation -> Commission Posting): a run is generated (rows computed and saved as a
|
||||||
|
* preview, nothing else changes), then posted (a separate, explicit step that applies exactly the
|
||||||
|
* rows that were previewed - never a fresh recalculation, so what gets applied is always what was
|
||||||
|
* seen).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_dds_batch_run")
|
||||||
|
public class BatchRun extends AbstractAuditableCustom {
|
||||||
|
|
||||||
|
@Column(name = "job_type", length = 40, nullable = false)
|
||||||
|
private String jobType;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
|
||||||
|
@Column(name = "agent_from", nullable = false)
|
||||||
|
private Long agentFrom;
|
||||||
|
|
||||||
|
@Column(name = "agent_to", nullable = false)
|
||||||
|
private Long agentTo;
|
||||||
|
|
||||||
|
@Column(name = "party_from")
|
||||||
|
private Long partyFrom;
|
||||||
|
|
||||||
|
@Column(name = "party_to")
|
||||||
|
private Long partyTo;
|
||||||
|
|
||||||
|
@Column(name = "status", length = 20, nullable = false)
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
|
||||||
|
@Column(name = "generated_on_date", nullable = false)
|
||||||
|
private LocalDate generatedOnDate;
|
||||||
|
|
||||||
|
@Column(name = "posted_on_date")
|
||||||
|
private LocalDate postedOnDate;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "batchRun", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||||
|
@OrderBy("id")
|
||||||
|
private List<BatchRunRow> rows = new ArrayList<>();
|
||||||
|
|
||||||
|
protected BatchRun() {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
this.partyFrom = partyFrom;
|
||||||
|
this.partyTo = partyTo;
|
||||||
|
this.status = "CALCULATED";
|
||||||
|
this.totalRows = 0;
|
||||||
|
this.totalAmount = BigDecimal.ZERO;
|
||||||
|
this.generatedOnDate = generatedOnDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addRow(final BatchRunRow row) {
|
||||||
|
row.setBatchRun(this);
|
||||||
|
this.rows.add(row);
|
||||||
|
this.totalRows = this.rows.size();
|
||||||
|
this.totalAmount = this.totalAmount.add(row.getAmount());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isPosted() {
|
||||||
|
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 = 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";
|
||||||
|
}
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* 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.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 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
|
||||||
|
@Table(name = "m_dds_batch_run_row")
|
||||||
|
public class BatchRunRow extends AbstractPersistableCustom<Long> {
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "batch_run_id", nullable = false)
|
||||||
|
private BatchRun batchRun;
|
||||||
|
|
||||||
|
@Column(name = "party_id")
|
||||||
|
private Long partyId;
|
||||||
|
|
||||||
|
@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() {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
this.batchRun = batchRun;
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.portfolio.dds.batch.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when Post is attempted on a batch run that has already been posted - a run applies once,
|
||||||
|
* matching the calculate-then-post safety control (never re-post the same preview).
|
||||||
|
*/
|
||||||
|
public class BatchRunAlreadyPostedException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public BatchRunAlreadyPostedException(final Long batchRunId) {
|
||||||
|
super("error.msg.ddsbatchrun.already.posted", "DDS batch run " + batchRunId + " has already been posted", batchRunId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.batch.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||||
|
|
||||||
|
public class BatchRunNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||||
|
|
||||||
|
public BatchRunNotFoundException(final Long id) {
|
||||||
|
super("error.msg.ddsbatchrun.id.invalid", "DDS batch run with identifier " + id + " does not exist", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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;
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
/** Days paid ahead, or days of arrears cleared - empty on an ordinary day's collection. */
|
||||||
|
private final Integer advanceDays;
|
||||||
|
private final Integer arrearsDays;
|
||||||
|
}
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The application of the cashier's DDS receipts that settled this entry, when it was settled that
|
||||||
|
* way - see 0517, which records which vouchers it used. A plain id for the same reason as the
|
||||||
|
* transaction.
|
||||||
|
*/
|
||||||
|
@Column(name = "receipt_application_id")
|
||||||
|
private Long receiptApplicationId;
|
||||||
|
|
||||||
|
/** Days paid ahead, or days of arrears cleared - see 0520. Both empty on an ordinary day's collection. */
|
||||||
|
@Column(name = "advance_days")
|
||||||
|
private Integer advanceDays;
|
||||||
|
|
||||||
|
@Column(name = "arrears_days")
|
||||||
|
private Integer arrearsDays;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records what the collection covers beyond today, once its amount has been checked against it. */
|
||||||
|
public void covering(final Integer advanceDays, final Integer arrearsDays) {
|
||||||
|
this.advanceDays = advanceDays;
|
||||||
|
this.arrearsDays = arrearsDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settled against the cashier's receipts: the same as {@link #markSettled}, and which application did it. */
|
||||||
|
public void markSettledByReceipt(final LocalDate settledOn, final Long partyTransactionId, final Long receiptApplicationId) {
|
||||||
|
markSettled(settledOn, partyTransactionId);
|
||||||
|
this.receiptApplicationId = receiptApplicationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+182
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* 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.data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Collection;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable data object representing a DDS Party (customer scheme account), including its nominees.
|
||||||
|
* Built via the Lombok builder rather than a long positional constructor - the field count here makes
|
||||||
|
* positional args unreadable and error-prone to keep in sync.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder(toBuilder = true)
|
||||||
|
public final class PartyData implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final Long clientId;
|
||||||
|
private final String clientName;
|
||||||
|
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;
|
||||||
|
/** The DDS account's own number. */
|
||||||
|
private final String accountNo;
|
||||||
|
private final String lastAccount;
|
||||||
|
private final Boolean nominal;
|
||||||
|
private final String aadharNo;
|
||||||
|
private final String partyName;
|
||||||
|
private final String partyName1;
|
||||||
|
private final String address1;
|
||||||
|
private final String address2;
|
||||||
|
private final String address3;
|
||||||
|
private final String city;
|
||||||
|
private final String pincode;
|
||||||
|
private final String gender;
|
||||||
|
private final String panCardNo;
|
||||||
|
private final String telephone;
|
||||||
|
private final String mobileNoSms;
|
||||||
|
private final String operation;
|
||||||
|
private final LocalDate birthDate;
|
||||||
|
private final String occupation;
|
||||||
|
private final String risk;
|
||||||
|
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;
|
||||||
|
private final LocalDate openingDate;
|
||||||
|
private final Integer month;
|
||||||
|
private final LocalDate maturityDate;
|
||||||
|
private final LocalDate reopenDate;
|
||||||
|
private final BigDecimal depositAmount;
|
||||||
|
private final BigDecimal dailyDepositAmount;
|
||||||
|
private final Integer days;
|
||||||
|
private final String ledgerNo;
|
||||||
|
private final BigDecimal maturityAmount;
|
||||||
|
private final BigDecimal interestRate;
|
||||||
|
private final String withdrawType;
|
||||||
|
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;
|
||||||
|
private final String creditToBranch;
|
||||||
|
private final String creditToAccountType;
|
||||||
|
private final String creditToAccount;
|
||||||
|
private final Boolean nomineeApplicable;
|
||||||
|
private final String nomineeSplitMode;
|
||||||
|
private final String status;
|
||||||
|
private final LocalDate closeDate;
|
||||||
|
private final Collection<PartyNomineeData> nominees;
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* 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.data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public final class PartyNomineeData implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final LocalDate nominationDate;
|
||||||
|
private final String name;
|
||||||
|
private final String mobileNo;
|
||||||
|
private final LocalDate birthDate;
|
||||||
|
private final Boolean minor;
|
||||||
|
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,
|
||||||
|
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 BigDecimal sharePercent, final Integer sequenceNo) {
|
||||||
|
this.id = id;
|
||||||
|
this.nominationDate = nominationDate;
|
||||||
|
this.name = name;
|
||||||
|
this.mobileNo = mobileNo;
|
||||||
|
this.birthDate = birthDate;
|
||||||
|
this.minor = minor;
|
||||||
|
this.relation = relation;
|
||||||
|
this.gender = gender;
|
||||||
|
this.guardianName = guardianName;
|
||||||
|
this.sharePercent = sharePercent;
|
||||||
|
this.sequenceNo = sequenceNo;
|
||||||
|
}
|
||||||
|
}
|
||||||
+791
@@ -0,0 +1,791 @@
|
|||||||
|
/**
|
||||||
|
* 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 jakarta.persistence.CascadeType;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
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;
|
||||||
|
import java.util.Map;
|
||||||
|
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.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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A DDS Party - the customer's daily-deposit scheme account, linked to the Agent who collects for it.
|
||||||
|
* Carries the scheme's KYC, terms and two-stage overdue flags, plus a nominee list.
|
||||||
|
*
|
||||||
|
* The underlying RD (recurring deposit) account this scheme conceptually reuses is not wired up yet -
|
||||||
|
* {@code savingsAccountId} is a nullable placeholder for that future linkage (see the DDS architecture
|
||||||
|
* decision to reuse the RD engine for the deposit half). Until then the scheme/balance fields below
|
||||||
|
* are this record's own, not mirrored from any m_savings_account row.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_dds_party", uniqueConstraints = { @UniqueConstraint(columnNames = { "party_code" }, name = "party_code_UNIQUE") })
|
||||||
|
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";
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "client_id", nullable = false)
|
||||||
|
private Client client;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@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;
|
||||||
|
|
||||||
|
@Column(name = "sub_type", length = 60, nullable = false)
|
||||||
|
private String subType;
|
||||||
|
|
||||||
|
@Column(name = "party_code", length = 20, nullable = false)
|
||||||
|
private String partyCode;
|
||||||
|
|
||||||
|
/** The DDS account's own number - see 0514. Given once the party has an id, and never changed. */
|
||||||
|
@Column(name = "account_no", length = 20)
|
||||||
|
private String accountNo;
|
||||||
|
|
||||||
|
@Column(name = "last_account", length = 50)
|
||||||
|
private String lastAccount;
|
||||||
|
|
||||||
|
@Column(name = "nominal", nullable = false)
|
||||||
|
private boolean nominal;
|
||||||
|
|
||||||
|
@Column(name = "aadhar_no", length = 20)
|
||||||
|
private String aadharNo;
|
||||||
|
|
||||||
|
@Column(name = "party_name", length = 100, nullable = false)
|
||||||
|
private String partyName;
|
||||||
|
|
||||||
|
@Column(name = "party_name1", length = 100)
|
||||||
|
private String partyName1;
|
||||||
|
|
||||||
|
@Column(name = "address1", length = 200, nullable = false)
|
||||||
|
private String address1;
|
||||||
|
|
||||||
|
@Column(name = "address2", length = 200)
|
||||||
|
private String address2;
|
||||||
|
|
||||||
|
@Column(name = "address3", length = 200)
|
||||||
|
private String address3;
|
||||||
|
|
||||||
|
@Column(name = "city", length = 100, nullable = false)
|
||||||
|
private String city;
|
||||||
|
|
||||||
|
@Column(name = "pincode", length = 20, nullable = false)
|
||||||
|
private String pincode;
|
||||||
|
|
||||||
|
@Column(name = "gender", length = 20)
|
||||||
|
private String gender;
|
||||||
|
|
||||||
|
@Column(name = "pan_card_no", length = 20)
|
||||||
|
private String panCardNo;
|
||||||
|
|
||||||
|
@Column(name = "telephone", length = 20)
|
||||||
|
private String telephone;
|
||||||
|
|
||||||
|
@Column(name = "mobile_no_sms", length = 20, nullable = false)
|
||||||
|
private String mobileNoSms;
|
||||||
|
|
||||||
|
@Column(name = "operation", length = 20, nullable = false)
|
||||||
|
private String operation;
|
||||||
|
|
||||||
|
@Column(name = "birth_date")
|
||||||
|
private LocalDate birthDate;
|
||||||
|
|
||||||
|
@Column(name = "occupation", length = 40)
|
||||||
|
private String occupation;
|
||||||
|
|
||||||
|
@Column(name = "risk", length = 20)
|
||||||
|
private String risk;
|
||||||
|
|
||||||
|
@Column(name = "ckyc_id", length = 40)
|
||||||
|
private String ckycId;
|
||||||
|
|
||||||
|
@Column(name = "adult_minor", length = 20, nullable = false)
|
||||||
|
private String adultMinor;
|
||||||
|
|
||||||
|
@Column(name = "minor_date")
|
||||||
|
private LocalDate minorDate;
|
||||||
|
|
||||||
|
@Column(name = "guardian_client_id")
|
||||||
|
private Long guardianClientId;
|
||||||
|
|
||||||
|
@Column(name = "min_account_opt", length = 40)
|
||||||
|
private String minAccountOpt;
|
||||||
|
|
||||||
|
@Column(name = "relation", length = 40)
|
||||||
|
private String relation;
|
||||||
|
|
||||||
|
@Column(name = "opening_date", nullable = false)
|
||||||
|
private LocalDate openingDate;
|
||||||
|
|
||||||
|
@Column(name = "months")
|
||||||
|
private Integer month;
|
||||||
|
|
||||||
|
@Column(name = "maturity_date")
|
||||||
|
private LocalDate maturityDate;
|
||||||
|
|
||||||
|
@Column(name = "reopen_date")
|
||||||
|
private LocalDate reopenDate;
|
||||||
|
|
||||||
|
@Column(name = "deposit_amount", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal depositAmount;
|
||||||
|
|
||||||
|
@Column(name = "daily_deposit_amount", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal dailyDepositAmount;
|
||||||
|
|
||||||
|
@Column(name = "days", nullable = false)
|
||||||
|
private Integer days;
|
||||||
|
|
||||||
|
@Column(name = "ledger_no", length = 40)
|
||||||
|
private String ledgerNo;
|
||||||
|
|
||||||
|
@Column(name = "maturity_amount", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal maturityAmount;
|
||||||
|
|
||||||
|
@Column(name = "interest_rate", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal interestRate;
|
||||||
|
|
||||||
|
@Column(name = "withdraw_type", length = 20)
|
||||||
|
private String withdrawType;
|
||||||
|
|
||||||
|
@Column(name = "od1_flag", nullable = false)
|
||||||
|
private boolean od1Flag;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
|
||||||
|
@Column(name = "payment_amount", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal paymentAmount;
|
||||||
|
|
||||||
|
@Column(name = "current_balance", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal currentBalance;
|
||||||
|
|
||||||
|
@Column(name = "opening_balance", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal openingBalance;
|
||||||
|
|
||||||
|
@Column(name = "is_sms_active", nullable = false)
|
||||||
|
private boolean isSmsActive;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
|
||||||
|
@Column(name = "freeze_code", length = 20, nullable = false)
|
||||||
|
private String freezeCode;
|
||||||
|
|
||||||
|
@Column(name = "adv_code", length = 40, nullable = false)
|
||||||
|
private String advCode;
|
||||||
|
|
||||||
|
@Column(name = "credit_to_branch", length = 100)
|
||||||
|
private String creditToBranch;
|
||||||
|
|
||||||
|
@Column(name = "credit_to_account_type", length = 100)
|
||||||
|
private String creditToAccountType;
|
||||||
|
|
||||||
|
@Column(name = "credit_to_account", length = 50)
|
||||||
|
private String creditToAccount;
|
||||||
|
|
||||||
|
@Column(name = "nominee_applicable", nullable = false)
|
||||||
|
private boolean nomineeApplicable;
|
||||||
|
|
||||||
|
@Column(name = "nominee_split_mode", length = 20)
|
||||||
|
private String nomineeSplitMode;
|
||||||
|
|
||||||
|
@Column(name = "status", length = 20, nullable = false)
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Column(name = "close_date")
|
||||||
|
private LocalDate closeDate;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "party", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||||
|
@OrderBy("id")
|
||||||
|
private List<PartyNominee> nominees = new ArrayList<>();
|
||||||
|
|
||||||
|
protected Party() {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
this.nominal = command.booleanPrimitiveValueOfParameterNamed("nominal");
|
||||||
|
this.aadharNo = command.stringValueOfParameterNamedAllowingNull("aadharNo");
|
||||||
|
this.partyName = command.stringValueOfParameterNamed("partyName");
|
||||||
|
this.partyName1 = command.stringValueOfParameterNamedAllowingNull("partyName1");
|
||||||
|
this.address1 = command.stringValueOfParameterNamed("address1");
|
||||||
|
this.address2 = command.stringValueOfParameterNamedAllowingNull("address2");
|
||||||
|
this.address3 = command.stringValueOfParameterNamedAllowingNull("address3");
|
||||||
|
this.city = command.stringValueOfParameterNamed("city");
|
||||||
|
this.pincode = command.stringValueOfParameterNamed("pincode");
|
||||||
|
this.gender = command.stringValueOfParameterNamedAllowingNull("gender");
|
||||||
|
this.panCardNo = command.stringValueOfParameterNamedAllowingNull("panCardNo");
|
||||||
|
this.telephone = command.stringValueOfParameterNamedAllowingNull("telephone");
|
||||||
|
this.mobileNoSms = command.stringValueOfParameterNamed("mobileNoSms");
|
||||||
|
this.operation = command.stringValueOfParameterNamed("operation");
|
||||||
|
this.birthDate = command.localDateValueOfParameterNamed("birthDate");
|
||||||
|
this.occupation = command.stringValueOfParameterNamedAllowingNull("occupation");
|
||||||
|
this.risk = command.stringValueOfParameterNamedAllowingNull("risk");
|
||||||
|
this.ckycId = command.stringValueOfParameterNamedAllowingNull("ckycId");
|
||||||
|
this.adultMinor = command.stringValueOfParameterNamed("adultMinor");
|
||||||
|
this.minorDate = command.localDateValueOfParameterNamed("minorDate");
|
||||||
|
this.guardianClientId = command.longValueOfParameterNamed("guardianClientId");
|
||||||
|
this.minAccountOpt = command.stringValueOfParameterNamedAllowingNull("minAccountOpt");
|
||||||
|
this.relation = command.stringValueOfParameterNamedAllowingNull("relation");
|
||||||
|
this.openingDate = command.localDateValueOfParameterNamed("openingDate");
|
||||||
|
this.month = command.integerValueOfParameterNamedDefaultToNullIfZero("month");
|
||||||
|
// 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");
|
||||||
|
this.days = command.integerValueOfParameterNamed("days");
|
||||||
|
this.ledgerNo = command.stringValueOfParameterNamedAllowingNull("ledgerNo");
|
||||||
|
this.maturityAmount = BigDecimal.ZERO;
|
||||||
|
this.interestRate = command.parameterExists("interestRate")
|
||||||
|
? command.bigDecimalValueOfParameterNamed("interestRate")
|
||||||
|
: DEFAULT_INTEREST_RATE;
|
||||||
|
this.withdrawType = command.stringValueOfParameterNamedAllowingNull("withdrawType");
|
||||||
|
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"));
|
||||||
|
this.currentBalance = this.openingBalance;
|
||||||
|
this.isSmsActive = command.booleanPrimitiveValueOfParameterNamed("isSmsActive");
|
||||||
|
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");
|
||||||
|
this.creditToBranch = command.stringValueOfParameterNamedAllowingNull("creditToBranch");
|
||||||
|
this.creditToAccountType = command.stringValueOfParameterNamedAllowingNull("creditToAccountType");
|
||||||
|
this.creditToAccount = command.stringValueOfParameterNamedAllowingNull("creditToAccount");
|
||||||
|
this.nomineeApplicable = command.booleanPrimitiveValueOfParameterNamed("nomineeApplicable");
|
||||||
|
this.nomineeSplitMode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("nomineeSplitMode"),
|
||||||
|
"Percentage");
|
||||||
|
this.status = STATUS_ACTIVE;
|
||||||
|
|
||||||
|
recalculateTerm();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes the scheme's interest rate. The rate belongs to the scheme, not to the counter: it is set
|
||||||
|
* when the account is opened or moved to another scheme, and again on every open account whenever
|
||||||
|
* the bank changes the scheme's rate, with the maturity figure worked out again on it.
|
||||||
|
*/
|
||||||
|
public void takeSchemeRate(final BigDecimal schemeRate) {
|
||||||
|
this.interestRate = schemeRate == null ? BigDecimal.ZERO : schemeRate;
|
||||||
|
recalculateTerm();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
return value == null ? BigDecimal.ZERO : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addNominee(final PartyNominee nominee) {
|
||||||
|
nominee.setParty(this);
|
||||||
|
this.nominees.add(nominee);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void resetNominees() {
|
||||||
|
this.nominees.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void changeClient(final Client newClient) {
|
||||||
|
this.client = newClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void changeAgent(final Agent newAgent) {
|
||||||
|
this.agent = newAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Links this Party to an existing real Savings Account, resolved by account number in the write
|
||||||
|
* service. There is no unlink - once a Party's DDS scheme is tied to a real account, it stays
|
||||||
|
* tied.
|
||||||
|
*/
|
||||||
|
public void linkSavingsAccount(final Long savingsAccountId) {
|
||||||
|
this.savingsAccountId = savingsAccountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Numbers the account from its id, the same way 0514 numbered the accounts already open. */
|
||||||
|
public void assignAccountNo() {
|
||||||
|
if (this.accountNo == null && getId() != null) {
|
||||||
|
this.accountNo = "DDS" + String.format("%09d", getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal credit(final BigDecimal amount) {
|
||||||
|
this.currentBalance = this.currentBalance.add(amount);
|
||||||
|
return this.currentBalance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal debit(final BigDecimal amount) {
|
||||||
|
this.currentBalance = this.currentBalance.subtract(amount);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes this Party's account as of the given date. The balance must already be zero - a Party
|
||||||
|
* is settled to zero (a full Withdraw Deposite/payout) before it is closed, not closed with money
|
||||||
|
* still owed to it.
|
||||||
|
*/
|
||||||
|
public void close(final LocalDate closeDate) {
|
||||||
|
this.status = STATUS_CLOSED;
|
||||||
|
this.closeDate = closeDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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() {
|
||||||
|
return this.client.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long agentId() {
|
||||||
|
return this.agent.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> update(final JsonCommand command) {
|
||||||
|
|
||||||
|
final Map<String, Object> actualChanges = new LinkedHashMap<>(40);
|
||||||
|
|
||||||
|
if (command.isChangeInLongParameterNamed("clientId", this.client.getId())) {
|
||||||
|
actualChanges.put("clientId", command.longValueOfParameterNamed("clientId"));
|
||||||
|
}
|
||||||
|
if (command.isChangeInLongParameterNamed("agentId", this.agent.getId())) {
|
||||||
|
actualChanges.put("agentId", command.longValueOfParameterNamed("agentId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
stringChange(command, actualChanges, "subType", this.subType, v -> this.subType = v);
|
||||||
|
stringChange(command, actualChanges, "lastAccount", this.lastAccount, v -> this.lastAccount = v);
|
||||||
|
|
||||||
|
if (command.isChangeInBooleanParameterNamed("nominal", this.nominal)) {
|
||||||
|
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("nominal");
|
||||||
|
actualChanges.put("nominal", newValue);
|
||||||
|
this.nominal = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
stringChange(command, actualChanges, "aadharNo", this.aadharNo, v -> this.aadharNo = v);
|
||||||
|
stringChange(command, actualChanges, "partyName", this.partyName, v -> this.partyName = v);
|
||||||
|
stringChange(command, actualChanges, "partyName1", this.partyName1, v -> this.partyName1 = v);
|
||||||
|
stringChange(command, actualChanges, "address1", this.address1, v -> this.address1 = v);
|
||||||
|
stringChange(command, actualChanges, "address2", this.address2, v -> this.address2 = v);
|
||||||
|
stringChange(command, actualChanges, "address3", this.address3, v -> this.address3 = v);
|
||||||
|
stringChange(command, actualChanges, "city", this.city, v -> this.city = v);
|
||||||
|
stringChange(command, actualChanges, "pincode", this.pincode, v -> this.pincode = v);
|
||||||
|
stringChange(command, actualChanges, "gender", this.gender, v -> this.gender = v);
|
||||||
|
stringChange(command, actualChanges, "panCardNo", this.panCardNo, v -> this.panCardNo = v);
|
||||||
|
stringChange(command, actualChanges, "telephone", this.telephone, v -> this.telephone = v);
|
||||||
|
stringChange(command, actualChanges, "mobileNoSms", this.mobileNoSms, v -> this.mobileNoSms = v);
|
||||||
|
stringChange(command, actualChanges, "operation", this.operation, v -> this.operation = v);
|
||||||
|
|
||||||
|
if (command.isChangeInDateParameterNamed("birthDate", this.birthDate)) {
|
||||||
|
actualChanges.put("birthDate", command.stringValueOfParameterNamed("birthDate"));
|
||||||
|
this.birthDate = command.localDateValueOfParameterNamed("birthDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
stringChange(command, actualChanges, "occupation", this.occupation, v -> this.occupation = v);
|
||||||
|
stringChange(command, actualChanges, "risk", this.risk, v -> this.risk = v);
|
||||||
|
stringChange(command, actualChanges, "ckycId", this.ckycId, v -> this.ckycId = v);
|
||||||
|
stringChange(command, actualChanges, "adultMinor", this.adultMinor, v -> this.adultMinor = v);
|
||||||
|
|
||||||
|
if (command.isChangeInDateParameterNamed("minorDate", this.minorDate)) {
|
||||||
|
actualChanges.put("minorDate", command.stringValueOfParameterNamed("minorDate"));
|
||||||
|
this.minorDate = command.localDateValueOfParameterNamed("minorDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInLongParameterNamed("guardianClientId", this.guardianClientId)) {
|
||||||
|
final Long newValue = command.longValueOfParameterNamed("guardianClientId");
|
||||||
|
actualChanges.put("guardianClientId", newValue);
|
||||||
|
this.guardianClientId = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
stringChange(command, actualChanges, "minAccountOpt", this.minAccountOpt, v -> this.minAccountOpt = v);
|
||||||
|
stringChange(command, actualChanges, "relation", this.relation, v -> this.relation = v);
|
||||||
|
|
||||||
|
if (command.isChangeInDateParameterNamed("openingDate", this.openingDate)) {
|
||||||
|
actualChanges.put("openingDate", command.stringValueOfParameterNamed("openingDate"));
|
||||||
|
this.openingDate = command.localDateValueOfParameterNamed("openingDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInIntegerParameterNamed("month", this.month)) {
|
||||||
|
final Integer newValue = command.integerValueOfParameterNamed("month");
|
||||||
|
actualChanges.put("month", newValue);
|
||||||
|
this.month = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInDateParameterNamed("reopenDate", this.reopenDate)) {
|
||||||
|
actualChanges.put("reopenDate", command.stringValueOfParameterNamed("reopenDate"));
|
||||||
|
this.reopenDate = command.localDateValueOfParameterNamed("reopenDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInBigDecimalParameterNamed("depositAmount", this.depositAmount)) {
|
||||||
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("depositAmount");
|
||||||
|
actualChanges.put("depositAmount", newValue);
|
||||||
|
this.depositAmount = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInBigDecimalParameterNamed("dailyDepositAmount", this.dailyDepositAmount)) {
|
||||||
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("dailyDepositAmount");
|
||||||
|
actualChanges.put("dailyDepositAmount", newValue);
|
||||||
|
this.dailyDepositAmount = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInIntegerParameterNamed("days", this.days)) {
|
||||||
|
final Integer newValue = command.integerValueOfParameterNamed("days");
|
||||||
|
actualChanges.put("days", newValue);
|
||||||
|
this.days = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rate is not edited here: it is the scheme's, taken when the account is opened or moved
|
||||||
|
// to another scheme - see takeSchemeRate.
|
||||||
|
|
||||||
|
stringChange(command, actualChanges, "ledgerNo", this.ledgerNo, v -> this.ledgerNo = v);
|
||||||
|
stringChange(command, actualChanges, "withdrawType", this.withdrawType, v -> this.withdrawType = v);
|
||||||
|
|
||||||
|
if (command.isChangeInBooleanParameterNamed("lien", this.lien)) {
|
||||||
|
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("lien");
|
||||||
|
actualChanges.put("lien", newValue);
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInBigDecimalParameterNamed("paymentAmount", this.paymentAmount)) {
|
||||||
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("paymentAmount");
|
||||||
|
actualChanges.put("paymentAmount", newValue);
|
||||||
|
this.paymentAmount = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInBigDecimalParameterNamed("openingBalance", this.openingBalance)) {
|
||||||
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("openingBalance");
|
||||||
|
actualChanges.put("openingBalance", newValue);
|
||||||
|
this.openingBalance = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInBooleanParameterNamed("isSmsActive", this.isSmsActive)) {
|
||||||
|
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("isSmsActive");
|
||||||
|
actualChanges.put("isSmsActive", newValue);
|
||||||
|
this.isSmsActive = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInBooleanParameterNamed("isEmailActive", this.isEmailActive)) {
|
||||||
|
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("isEmailActive");
|
||||||
|
actualChanges.put("isEmailActive", newValue);
|
||||||
|
this.isEmailActive = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInDateParameterNamed("interestDate", this.interestDate)) {
|
||||||
|
actualChanges.put("interestDate", command.stringValueOfParameterNamed("interestDate"));
|
||||||
|
this.interestDate = command.localDateValueOfParameterNamed("interestDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
stringChange(command, actualChanges, "spDescription", this.spDescription, v -> this.spDescription = v);
|
||||||
|
stringChange(command, actualChanges, "freezeCode", this.freezeCode, v -> this.freezeCode = v);
|
||||||
|
stringChange(command, actualChanges, "advCode", this.advCode, v -> this.advCode = v);
|
||||||
|
stringChange(command, actualChanges, "creditToBranch", this.creditToBranch, v -> this.creditToBranch = v);
|
||||||
|
stringChange(command, actualChanges, "creditToAccountType", this.creditToAccountType, v -> this.creditToAccountType = v);
|
||||||
|
stringChange(command, actualChanges, "creditToAccount", this.creditToAccount, v -> this.creditToAccount = v);
|
||||||
|
|
||||||
|
if (command.isChangeInBooleanParameterNamed("nomineeApplicable", this.nomineeApplicable)) {
|
||||||
|
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("nomineeApplicable");
|
||||||
|
actualChanges.put("nomineeApplicable", newValue);
|
||||||
|
this.nomineeApplicable = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stringChange(final JsonCommand command, final Map<String, Object> actualChanges, final String paramName,
|
||||||
|
final String existingValue, final Consumer<String> setter) {
|
||||||
|
if (command.isChangeInStringParameterNamed(paramName, existingValue)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed(paramName);
|
||||||
|
actualChanges.put(paramName, newValue);
|
||||||
|
setter.accept(newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* 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 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.commons.lang3.StringUtils;
|
||||||
|
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 AbstractAuditableCustom {
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "party_id", nullable = false)
|
||||||
|
private Party party;
|
||||||
|
|
||||||
|
@Column(name = "nomination_date", nullable = false)
|
||||||
|
private LocalDate nominationDate;
|
||||||
|
|
||||||
|
@Column(name = "name", length = 100, nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "mobile_no", length = 20)
|
||||||
|
private String mobileNo;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
|
||||||
|
@Column(name = "gender", length = 20, nullable = false)
|
||||||
|
private String gender;
|
||||||
|
|
||||||
|
@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 String relation, final String gender, final String guardianName, final BigDecimal sharePercent,
|
||||||
|
final Integer sequenceNo) {
|
||||||
|
this.nominationDate = nominationDate;
|
||||||
|
// 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 = 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.portfolio.dds.party.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a Close Party command, or a Party Tran collection, targets a Party that is already
|
||||||
|
* closed.
|
||||||
|
*/
|
||||||
|
public class PartyAlreadyClosedException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public PartyAlreadyClosedException(final String partyCode) {
|
||||||
|
super("error.msg.party.already.closed", "Party " + partyCode + " is already closed", partyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.portfolio.dds.party.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a Close Party command is attempted while the Party's balance is still non-zero - the
|
||||||
|
* remaining balance must be paid out (a Withdraw Deposite to zero) before the account can be closed.
|
||||||
|
*/
|
||||||
|
public class PartyHasBalanceException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public PartyHasBalanceException(final String partyCode) {
|
||||||
|
super("error.msg.party.has.balance", "Party " + partyCode + " still has a non-zero balance and cannot be closed", 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 org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||||
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link RuntimeException} thrown when a DDS party resource is not found.
|
||||||
|
*/
|
||||||
|
public class PartyNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||||
|
|
||||||
|
public PartyNotFoundException(final Long id) {
|
||||||
|
super("error.msg.party.id.invalid", "Party with identifier " + id + " does not exist", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PartyNotFoundException(final Long id, final EmptyResultDataAccessException e) {
|
||||||
|
super("error.msg.party.id.invalid", "Party with identifier " + id + " does not exist", id, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PartyNotFoundException(final String partyCode) {
|
||||||
|
super("error.msg.party.code.invalid", "Party with code " + partyCode + " does not exist", partyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* 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 the savingsAccountNo given for a Party belongs to a different client than the Party's
|
||||||
|
* own clientId - a Party can only be linked to its own client's savings account.
|
||||||
|
*/
|
||||||
|
public class PartySavingsAccountClientMismatchException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public PartySavingsAccountClientMismatchException(final String savingsAccountNo) {
|
||||||
|
super("error.msg.party.savings.account.client.mismatch",
|
||||||
|
"Savings account " + savingsAccountNo + " does not belong to this party's client", savingsAccountNo);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.portfolio.dds.party.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a Party's savingsAccountNo does not match any non-closed Savings Account.
|
||||||
|
*/
|
||||||
|
public class PartySavingsAccountNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||||
|
|
||||||
|
public PartySavingsAccountNotFoundException(final String savingsAccountNo) {
|
||||||
|
super("error.msg.party.savings.account.not.found", "No open savings account found with account number " + savingsAccountNo,
|
||||||
|
savingsAccountNo);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* 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.report.data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A generic tabular result shape shared by every DDS report - one column list plus one row list, both
|
||||||
|
* keyed the same way, so a single report viewer screen can render any of them without a bespoke
|
||||||
|
* result type per report.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
public final class DdsReportData implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final String reportKey;
|
||||||
|
private final boolean available;
|
||||||
|
private final String message;
|
||||||
|
private final List<String> columns;
|
||||||
|
private final List<Map<String, Object>> rows;
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* 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.data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
public final class PartyTransactionData implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final Long partyId;
|
||||||
|
private final String partyCode;
|
||||||
|
private final String partyName;
|
||||||
|
private final Long agentId;
|
||||||
|
private final String agentCode;
|
||||||
|
private final LocalDate transactionDate;
|
||||||
|
private final String transactionType;
|
||||||
|
private final String direction;
|
||||||
|
private final String transactionMode;
|
||||||
|
private final String accountType;
|
||||||
|
private final String accountNo;
|
||||||
|
private final BigDecimal amount;
|
||||||
|
private final String chequeNo;
|
||||||
|
private final String tokenNo;
|
||||||
|
private final String description;
|
||||||
|
private final BigDecimal balanceAfterTransaction;
|
||||||
|
/** The DDS voucher this line moved under, e.g. DDS/29092026/0001; empty on lines from before vouchers. */
|
||||||
|
private final String voucherNo;
|
||||||
|
}
|
||||||
+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*"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* 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 jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.PostPersist;
|
||||||
|
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;
|
||||||
|
import org.apache.fineract.portfolio.dds.voucher.DdsVoucherScope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single posted entry on the DDS ledger: a Party's day's collection ("Party Tran", direction
|
||||||
|
* CREDIT), one leg of a "Withdraw Deposite" transfer between two parties (the creditor leg CREDIT,
|
||||||
|
* the debtor leg DEBIT), or an Agent's settlement of collected cash to its branch ("Agent Tran",
|
||||||
|
* direction DEBIT - no {@link Party} involved, so {@code party} is null for this type).
|
||||||
|
* {@code balanceAfterTransaction} holds the Party's balance for Party-linked rows, or the Agent's
|
||||||
|
* balance for an Agent Tran row. Immutable once posted - like any transaction, it is reversed with
|
||||||
|
* an opposing entry, not edited, so there is deliberately no update API for this entity.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_dds_party_transaction")
|
||||||
|
public class PartyTransaction extends AbstractAuditableCustom {
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "party_id")
|
||||||
|
private Party party;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "agent_id", nullable = false)
|
||||||
|
private Agent agent;
|
||||||
|
|
||||||
|
@Column(name = "transaction_date", nullable = false)
|
||||||
|
private LocalDate transactionDate;
|
||||||
|
|
||||||
|
@Column(name = "transaction_type", length = 30, nullable = false)
|
||||||
|
private String transactionType;
|
||||||
|
|
||||||
|
@Column(name = "direction", length = 10, nullable = false)
|
||||||
|
private String direction;
|
||||||
|
|
||||||
|
@Column(name = "transaction_mode", length = 20)
|
||||||
|
private String transactionMode;
|
||||||
|
|
||||||
|
@Column(name = "account_type", length = 100)
|
||||||
|
private String accountType;
|
||||||
|
|
||||||
|
@Column(name = "account_no", length = 50)
|
||||||
|
private String accountNo;
|
||||||
|
|
||||||
|
@Column(name = "amount", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
@Column(name = "cheque_no", length = 40)
|
||||||
|
private String chequeNo;
|
||||||
|
|
||||||
|
@Column(name = "token_no", length = 40)
|
||||||
|
private String tokenNo;
|
||||||
|
|
||||||
|
@Column(name = "description", length = 500)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "balance_after_transaction", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal balanceAfterTransaction;
|
||||||
|
|
||||||
|
protected PartyTransaction() {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public PartyTransaction(final Party party, final Agent agent, final LocalDate transactionDate, final String transactionType,
|
||||||
|
final String direction, final String transactionMode, final String accountType, final String accountNo,
|
||||||
|
final BigDecimal amount, final String chequeNo, final String tokenNo, final String description,
|
||||||
|
final BigDecimal balanceAfterTransaction) {
|
||||||
|
this.party = party;
|
||||||
|
this.agent = agent;
|
||||||
|
this.transactionDate = transactionDate;
|
||||||
|
this.transactionType = transactionType;
|
||||||
|
this.direction = direction;
|
||||||
|
this.transactionMode = transactionMode;
|
||||||
|
this.accountType = accountType;
|
||||||
|
this.accountNo = accountNo;
|
||||||
|
this.amount = amount;
|
||||||
|
this.chequeNo = chequeNo;
|
||||||
|
this.tokenNo = tokenNo;
|
||||||
|
this.description = description;
|
||||||
|
this.balanceAfterTransaction = balanceAfterTransaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notes this line against the operation that wrote it, so the operation's voucher number can be
|
||||||
|
* stamped on it. The voucher number itself is not mapped here: it is written once, by SQL, after
|
||||||
|
* the voucher is raised, and a mapped field would be written back as null by any later save.
|
||||||
|
*/
|
||||||
|
@PostPersist
|
||||||
|
private void recordInVoucherScope() {
|
||||||
|
DdsVoucherScope.recordPartyTransaction(getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* 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 an Agent Tran settlement amount exceeds the Agent's current (collected but not yet
|
||||||
|
* settled) balance.
|
||||||
|
*/
|
||||||
|
public class InsufficientAgentBalanceException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public InsufficientAgentBalanceException(final String agentCode) {
|
||||||
|
super("error.msg.agent.insufficient.balance",
|
||||||
|
"Agent " + agentCode + " does not have sufficient collected balance for this settlement", agentCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.portfolio.dds.transaction.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a Withdraw Deposite transfer would take the debtor Party's balance below zero.
|
||||||
|
*/
|
||||||
|
public class InsufficientPartyBalanceException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public InsufficientPartyBalanceException(final String partyCode) {
|
||||||
|
super("error.msg.party.insufficient.balance", "Party " + partyCode + " does not have sufficient balance for this transfer",
|
||||||
|
partyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.portfolio.dds.transaction.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||||
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link RuntimeException} thrown when a DDS party transaction resource is not found.
|
||||||
|
*/
|
||||||
|
public class PartyTransactionNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||||
|
|
||||||
|
public PartyTransactionNotFoundException(final Long id, final EmptyResultDataAccessException e) {
|
||||||
|
super("error.msg.partytransaction.id.invalid", "Party transaction with identifier " + id + " does not exist", id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* 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.voucher;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What one DDS operation moved, gathered while it runs so a single voucher can be raised for it at
|
||||||
|
* the end.
|
||||||
|
*
|
||||||
|
* A payout, an early closure, a posting run or a transfer each writes several ledger legs and
|
||||||
|
* passbook lines from several places - the interest brought up to date, the recovery, the payout
|
||||||
|
* itself. The voucher is the slip for the whole operation, not for each leg, so the legs are noted
|
||||||
|
* here as they are written and turned into one voucher when the operation finishes.
|
||||||
|
*
|
||||||
|
* Held per thread, since an operation runs on one thread from start to finish - including a posting
|
||||||
|
* run handed to a background thread, which opens its own scope there. Outside a scope nothing is
|
||||||
|
* recorded.
|
||||||
|
*/
|
||||||
|
public final class DdsVoucherScope {
|
||||||
|
|
||||||
|
/** A voucher side with no ledger leg behind it - a move between two accounts on the same GL. */
|
||||||
|
public record Line(String entryType, Long glAccountId, String partyName, String accountNo, BigDecimal amount, String description) {}
|
||||||
|
|
||||||
|
private static final ThreadLocal<DdsVoucherScope> CURRENT = new ThreadLocal<>();
|
||||||
|
|
||||||
|
private final Set<String> ledgerTransactionIds = new LinkedHashSet<>();
|
||||||
|
private final List<Long> partyTransactionIds = new ArrayList<>();
|
||||||
|
private final List<Line> extraLines = new ArrayList<>();
|
||||||
|
|
||||||
|
private DdsVoucherScope() {}
|
||||||
|
|
||||||
|
/** The scope now open on this thread, or null. */
|
||||||
|
public static DdsVoucherScope current() {
|
||||||
|
return CURRENT.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens a scope on this thread. The caller must {@link #close} it. */
|
||||||
|
public static DdsVoucherScope open() {
|
||||||
|
final DdsVoucherScope scope = new DdsVoucherScope();
|
||||||
|
CURRENT.set(scope);
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void close() {
|
||||||
|
CURRENT.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notes a ledger transaction written for the operation in progress. */
|
||||||
|
public static void recordLedgerTransaction(final String transactionId) {
|
||||||
|
final DdsVoucherScope scope = CURRENT.get();
|
||||||
|
if (scope != null && transactionId != null) {
|
||||||
|
scope.ledgerTransactionIds.add(transactionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notes a passbook line written for the operation in progress, so it can carry the voucher number. */
|
||||||
|
public static void recordPartyTransaction(final Long partyTransactionId) {
|
||||||
|
final DdsVoucherScope scope = CURRENT.get();
|
||||||
|
if (scope != null && partyTransactionId != null) {
|
||||||
|
scope.partyTransactionIds.add(partyTransactionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a voucher side the ledger does not show - see {@link Line}. */
|
||||||
|
public static void addLine(final Line line) {
|
||||||
|
final DdsVoucherScope scope = CURRENT.get();
|
||||||
|
if (scope != null && line != null) {
|
||||||
|
scope.extraLines.add(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<String> getLedgerTransactionIds() {
|
||||||
|
return Collections.unmodifiableSet(this.ledgerTransactionIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Long> getPartyTransactionIds() {
|
||||||
|
return Collections.unmodifiableList(this.partyTransactionIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Line> getExtraLines() {
|
||||||
|
return Collections.unmodifiableList(this.extraLines);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -278,6 +278,7 @@ public class SavingsApiConstants {
|
|||||||
// Additional custom fields
|
// Additional custom fields
|
||||||
public static final String balanceFrequencyParamName = "balanceFrequency";
|
public static final String balanceFrequencyParamName = "balanceFrequency";
|
||||||
public static final String accountClosedZeroBalanceInDayClosingParamName = "accountClosedZeroBalanceInDayClosing";
|
public static final String accountClosedZeroBalanceInDayClosingParamName = "accountClosedZeroBalanceInDayClosing";
|
||||||
|
public static final String productCategoryParamName = "productCategory";
|
||||||
|
|
||||||
// Current Product — new UI fields
|
// Current Product — new UI fields
|
||||||
public static final String balanceSwapOptionParamName = "balanceSwapOption";
|
public static final String balanceSwapOptionParamName = "balanceSwapOption";
|
||||||
|
|||||||
+1
@@ -144,6 +144,7 @@ public final class SavingsProductData implements Serializable {
|
|||||||
@Getter @Setter private Boolean enableLockinPeriod;
|
@Getter @Setter private Boolean enableLockinPeriod;
|
||||||
@Getter @Setter private String balanceFrequency;
|
@Getter @Setter private String balanceFrequency;
|
||||||
@Getter @Setter private Boolean accountClosedZeroBalanceInDayClosing;
|
@Getter @Setter private Boolean accountClosedZeroBalanceInDayClosing;
|
||||||
|
@Getter @Setter private String productCategory;
|
||||||
|
|
||||||
// New current product fields
|
// New current product fields
|
||||||
@Getter @Setter private Boolean balanceSwapOption;
|
@Getter @Setter private Boolean balanceSwapOption;
|
||||||
|
|||||||
+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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* 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.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.organisation.agent.data.AgentData;
|
||||||
|
import org.apache.fineract.organisation.agent.service.AgentReadPlatformService;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Path("/v1/dds/agents")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "DDS Agents", description = "A DDS collection agent - either a person or, at the top of the hierarchy, the bank itself - with its own settlement savings account and guarantors.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AgentsApiResource {
|
||||||
|
|
||||||
|
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "AGENT";
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final AgentReadPlatformService readPlatformService;
|
||||||
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Retrieve DDS Agents", description = "Returns the list of DDS agents.")
|
||||||
|
public Collection<AgentData> retrieveAll() {
|
||||||
|
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||||
|
return readPlatformService.retrieveAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("{agentId}")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Retrieve a DDS Agent", description = "Returns the details of a DDS agent, including its guarantors.")
|
||||||
|
public AgentData retrieveOne(@PathParam("agentId") @Parameter(description = "agentId") final Long agentId) {
|
||||||
|
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||||
|
return readPlatformService.retrieveOne(agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@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\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() //
|
||||||
|
.withJson(apiRequestBodyAsJson) //
|
||||||
|
.build();
|
||||||
|
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("{agentId}")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Update a DDS Agent", description = "Updates the details of a DDS agent.")
|
||||||
|
public CommandProcessingResult update(@PathParam("agentId") @Parameter(description = "agentId") final Long agentId,
|
||||||
|
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||||
|
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||||
|
.updateAgent(agentId) //
|
||||||
|
.withJson(apiRequestBodyAsJson) //
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* 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 java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
public interface AgentRepository extends JpaRepository<Agent, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client's agents still on the books. One person is one agent: a second would split their
|
||||||
|
* collections and commission between two codes paying into the same account.
|
||||||
|
*/
|
||||||
|
@Query("select a from Agent a where a.client.id = :clientId and a.active = true")
|
||||||
|
List<Agent> findActiveByClientId(@Param("clientId") Long clientId);
|
||||||
|
}
|
||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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 = "CREATE")
|
||||||
|
public class CreateAgentCommandHandler implements NewCommandSourceHandler {
|
||||||
|
|
||||||
|
private final AgentWritePlatformService writePlatformService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public CreateAgentCommandHandler(final AgentWritePlatformService writePlatformService) {
|
||||||
|
this.writePlatformService = writePlatformService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
|
return this.writePlatformService.createAgent(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.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 = "UPDATE")
|
||||||
|
public class UpdateAgentCommandHandler implements NewCommandSourceHandler {
|
||||||
|
|
||||||
|
private final AgentWritePlatformService writePlatformService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public UpdateAgentCommandHandler(final AgentWritePlatformService writePlatformService) {
|
||||||
|
this.writePlatformService = writePlatformService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
|
return this.writePlatformService.updateAgent(command.entityId(), command);
|
||||||
|
}
|
||||||
|
}
|
||||||
+482
@@ -0,0 +1,482 @@
|
|||||||
|
/**
|
||||||
|
* 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.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.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. 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 {
|
||||||
|
|
||||||
|
public static final String CLIENT_ID = "clientId";
|
||||||
|
public static final String AGENT_NAME_ID = "agentNameId";
|
||||||
|
public static final String AGENT_ADDRESS1 = "agentAddress1";
|
||||||
|
public static final String AGENT_ADDRESS2 = "agentAddress2";
|
||||||
|
public static final String AGENT_CITY = "agentCity";
|
||||||
|
public static final String AGENT_PINCODE = "agentPinCode";
|
||||||
|
public static final String AGENT_TELEPHONE = "agentTelephone";
|
||||||
|
public static final String AGENT_SAVING_ACCOUNT_TYPE = "agentSavingAccountType";
|
||||||
|
public static final String AGENT_SAVING_ACCOUNT_NO = "agentSavingAccountNo";
|
||||||
|
public static final String AGENT_OPENING_DATE = "agentOpeningDate";
|
||||||
|
public static final String AGENT_BIRTH_DATE = "agentBirthDate";
|
||||||
|
public static final String AGENT_OPENING_BALANCE = "agentOpeningBalance";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ages the bank takes agents on at: at least 16, and not yet 60. Kept as named constants
|
||||||
|
* rather than written into the check, because they are policy numbers the bank may one day want
|
||||||
|
* to read off, not an arithmetic detail.
|
||||||
|
*/
|
||||||
|
private static final int MINIMUM_AGENT_AGE_YEARS = 16;
|
||||||
|
private static final int MAXIMUM_AGENT_AGE_YEARS = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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";
|
||||||
|
private static final String GUARANTOR_ADDRESS2 = "address2";
|
||||||
|
private static final String GUARANTOR_CITY = "city";
|
||||||
|
private static final String GUARANTOR_PINCODE = "pinCode";
|
||||||
|
private static final String GUARANTOR_TELEPHONE = "telephone";
|
||||||
|
|
||||||
|
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, OFFICE_ID, GL_ACCOUNT_ID, ACTIVE, GUARANTORS, DATE_FORMAT,
|
||||||
|
LOCALE));
|
||||||
|
|
||||||
|
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
|
||||||
|
public AgentCommandFromApiJsonDeserializer(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_PARAMETERS);
|
||||||
|
|
||||||
|
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||||
|
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent");
|
||||||
|
|
||||||
|
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
||||||
|
|
||||||
|
final Long clientId = this.fromApiJsonHelper.extractLongNamed(CLIENT_ID, element);
|
||||||
|
baseDataValidator.reset().parameter(CLIENT_ID).value(clientId).notNull().integerGreaterThanZero();
|
||||||
|
|
||||||
|
final String agentName = this.fromApiJsonHelper.extractStringNamed(AGENT_NAME_ID, element);
|
||||||
|
baseDataValidator.reset().parameter(AGENT_NAME_ID).value(agentName).notBlank().notExceedingLengthOf(100);
|
||||||
|
|
||||||
|
final String agentAddress1 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS1, element);
|
||||||
|
baseDataValidator.reset().parameter(AGENT_ADDRESS1).value(agentAddress1).notBlank().notExceedingLengthOf(200);
|
||||||
|
|
||||||
|
if (this.fromApiJsonHelper.parameterExists(AGENT_ADDRESS2, element)) {
|
||||||
|
final String agentAddress2 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS2, element);
|
||||||
|
baseDataValidator.reset().parameter(AGENT_ADDRESS2).value(agentAddress2).ignoreIfNull().notExceedingLengthOf(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String agentCity = this.fromApiJsonHelper.extractStringNamed(AGENT_CITY, element);
|
||||||
|
baseDataValidator.reset().parameter(AGENT_CITY).value(agentCity).notBlank().notExceedingLengthOf(100);
|
||||||
|
|
||||||
|
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);
|
||||||
|
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_TYPE).value(agentSavingAccountType).notBlank()
|
||||||
|
.notExceedingLengthOf(100);
|
||||||
|
|
||||||
|
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, true);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.fromApiJsonHelper.parameterExists(GUARANTORS, element)) {
|
||||||
|
validateGuarantors(element.getAsJsonObject(), dataValidationErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent");
|
||||||
|
|
||||||
|
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
||||||
|
|
||||||
|
if (this.fromApiJsonHelper.parameterExists(CLIENT_ID, element)) {
|
||||||
|
final Long clientId = this.fromApiJsonHelper.extractLongNamed(CLIENT_ID, element);
|
||||||
|
baseDataValidator.reset().parameter(CLIENT_ID).value(clientId).notNull().integerGreaterThanZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.fromApiJsonHelper.parameterExists(AGENT_NAME_ID, element)) {
|
||||||
|
final String agentName = this.fromApiJsonHelper.extractStringNamed(AGENT_NAME_ID, element);
|
||||||
|
baseDataValidator.reset().parameter(AGENT_NAME_ID).value(agentName).notBlank().notExceedingLengthOf(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.fromApiJsonHelper.parameterExists(AGENT_ADDRESS1, element)) {
|
||||||
|
final String agentAddress1 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS1, element);
|
||||||
|
baseDataValidator.reset().parameter(AGENT_ADDRESS1).value(agentAddress1).notBlank().notExceedingLengthOf(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.fromApiJsonHelper.parameterExists(AGENT_ADDRESS2, element)) {
|
||||||
|
final String agentAddress2 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS2, element);
|
||||||
|
baseDataValidator.reset().parameter(AGENT_ADDRESS2).value(agentAddress2).ignoreIfNull().notExceedingLengthOf(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.fromApiJsonHelper.parameterExists(AGENT_CITY, element)) {
|
||||||
|
final String agentCity = this.fromApiJsonHelper.extractStringNamed(AGENT_CITY, element);
|
||||||
|
baseDataValidator.reset().parameter(AGENT_CITY).value(agentCity).notBlank().notExceedingLengthOf(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
final String agentSavingAccountType = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_TYPE, element);
|
||||||
|
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_TYPE).value(agentSavingAccountType).notBlank()
|
||||||
|
.notExceedingLengthOf(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.fromApiJsonHelper.parameterExists(AGENT_SAVING_ACCOUNT_NO, element)) {
|
||||||
|
final String agentSavingAccountNo = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_NO, element);
|
||||||
|
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_NO).value(agentSavingAccountNo).notBlank().notExceedingLengthOf(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_DATE, element)) {
|
||||||
|
final var agentOpeningDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_OPENING_DATE, element);
|
||||||
|
baseDataValidator.reset().parameter(AGENT_OPENING_DATE).value(agentOpeningDate).notNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
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, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)) {
|
||||||
|
final Boolean commissionCalculation = this.fromApiJsonHelper.extractBooleanNamed(COMMISSION_CALCULATION, element);
|
||||||
|
baseDataValidator.reset().parameter(COMMISSION_CALCULATION).value(commissionCalculation).notNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* The age range is applied when an agent is taken on. An agent already on the books is not
|
||||||
|
* refused an edit for having turned 60 since - their age was right when they were appointed.
|
||||||
|
*/
|
||||||
|
private void validateAgentBirthDate(final LocalDate agentBirthDate, final DataValidatorBuilder baseDataValidator,
|
||||||
|
final boolean appointing) {
|
||||||
|
if (agentBirthDate == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (!appointing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (agentBirthDate.isAfter(today.minusYears(MINIMUM_AGENT_AGE_YEARS))) {
|
||||||
|
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate)
|
||||||
|
.failWithCodeAndMessage("agent.must.be.at.least." + MINIMUM_AGENT_AGE_YEARS + ".years.old",
|
||||||
|
"An agent must be " + MINIMUM_AGENT_AGE_YEARS + " to " + (MAXIMUM_AGENT_AGE_YEARS - 1) + " years old.");
|
||||||
|
} else if (!agentBirthDate.isAfter(today.minusYears(MAXIMUM_AGENT_AGE_YEARS))) {
|
||||||
|
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate)
|
||||||
|
.failWithCodeAndMessage("agent.must.be.under." + MAXIMUM_AGENT_AGE_YEARS + ".years.old",
|
||||||
|
"An agent must be " + MINIMUM_AGENT_AGE_YEARS + " to " + (MAXIMUM_AGENT_AGE_YEARS - 1) + " 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);
|
||||||
|
|
||||||
|
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))
|
||||||
|
.notBlank().notExceedingLengthOf(200);
|
||||||
|
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_CITY, i).value(extractString(guarantorJson, GUARANTOR_CITY))
|
||||||
|
.notBlank().notExceedingLengthOf(100);
|
||||||
|
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_PINCODE, i).value(extractString(guarantorJson, GUARANTOR_PINCODE))
|
||||||
|
.notBlank().notExceedingLengthOf(20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractString(final JsonObject json, final String paramName) {
|
||||||
|
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long extractLong(final JsonObject json, final String paramName) {
|
||||||
|
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsLong() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private java.math.BigDecimal extractBigDecimal(final JsonObject json, final String paramName) {
|
||||||
|
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsBigDecimal() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
|
||||||
|
if (!dataValidationErrors.isEmpty()) {
|
||||||
|
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+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.organisation.agent.service;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import org.apache.fineract.organisation.agent.data.AgentData;
|
||||||
|
|
||||||
|
public interface AgentReadPlatformService {
|
||||||
|
|
||||||
|
Collection<AgentData> retrieveAll();
|
||||||
|
|
||||||
|
AgentData retrieveOne(Long agentId);
|
||||||
|
}
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* 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.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;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
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.exception.AgentNotFoundException;
|
||||||
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
|
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AgentReadPlatformServiceImpl implements AgentReadPlatformService {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
private static final AgentMapper AGENT_MAPPER = new AgentMapper();
|
||||||
|
private static final AgentGuarantorMapper GUARANTOR_MAPPER = new AgentGuarantorMapper();
|
||||||
|
|
||||||
|
private static final class AgentMapper implements RowMapper<AgentData> {
|
||||||
|
|
||||||
|
public String schema() {
|
||||||
|
return " a.id as id, a.client_id as clientId, c.display_name as clientName, a.agent_code as agentCode, "
|
||||||
|
+ " a.agent_name as agentNameId, a.agent_address1 as agentAddress1, a.agent_address2 as agentAddress2, "
|
||||||
|
+ " a.agent_city as agentCity, a.agent_pincode as agentPinCode, a.agent_telephone as agentTelephone, "
|
||||||
|
+ " 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, "
|
||||||
|
// 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.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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class AgentGuarantorMapper implements RowMapper<Map.Entry<Long, AgentGuarantorData>> {
|
||||||
|
|
||||||
|
public String schema() {
|
||||||
|
return " g.agent_id as agentId, g.id as id, g.client_id as clientId, g.guarantor_name as guarantorName, "
|
||||||
|
+ " g.address1 as address1, g.address2 as address2, g.city as city, g.pin_code as pinCode, "
|
||||||
|
+ " g.telephone as telephone from m_dds_agent_guarantor g";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map.Entry<Long, AgentGuarantorData> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||||
|
final Long agentId = rs.getLong("agentId");
|
||||||
|
final AgentGuarantorData data = AgentGuarantorData.instance(rs.getLong("id"), rs.getLong("clientId"),
|
||||||
|
rs.getString("guarantorName"), rs.getString("address1"), rs.getString("address2"), rs.getString("city"),
|
||||||
|
rs.getString("pinCode"), rs.getString("telephone"));
|
||||||
|
return Map.entry(agentId, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
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 attachGuarantors(agents);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AgentData retrieveOne(final Long agentId) {
|
||||||
|
try {
|
||||||
|
final String sql = "select " + AGENT_MAPPER.schema() + " where a.id = ?";
|
||||||
|
final AgentData agent = this.jdbcTemplate.queryForObject(sql, AGENT_MAPPER, agentId); // NOSONAR
|
||||||
|
return attachGuarantors(List.of(agent)).iterator().next();
|
||||||
|
} catch (final EmptyResultDataAccessException e) {
|
||||||
|
throw new AgentNotFoundException(agentId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Collection<AgentData> attachGuarantors(final List<AgentData> agents) {
|
||||||
|
if (agents.isEmpty()) {
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<>();
|
||||||
|
for (final Map.Entry<Long, AgentGuarantorData> row : guarantorRows) {
|
||||||
|
guarantorsByAgentId.computeIfAbsent(row.getKey(), k -> new ArrayList<>()).add(row.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<AgentData> result = new ArrayList<>(agents.size());
|
||||||
|
for (final AgentData agent : agents) {
|
||||||
|
result.add(agent.toBuilder().guarantors(guarantorsByAgentId.getOrDefault(agent.getId(), new ArrayList<>())).build());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.service;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
+404
@@ -0,0 +1,404 @@
|
|||||||
|
/**
|
||||||
|
* 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.service;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import jakarta.persistence.PersistenceException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.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.organisation.agent.domain.Agent;
|
||||||
|
import org.apache.fineract.organisation.agent.domain.AgentGuarantor;
|
||||||
|
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;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePlatformService {
|
||||||
|
|
||||||
|
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
|
||||||
|
public CommandProcessingResult createAgent(final JsonCommand command) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.fromApiJsonDeserializer.validateForCreate(command.json());
|
||||||
|
|
||||||
|
final Long clientId = command.longValueOfParameterNamed("clientId");
|
||||||
|
final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
|
||||||
|
|
||||||
|
refuseIfAlreadyAnAgent(clientId, null);
|
||||||
|
validateSavingsAccount(clientId, command);
|
||||||
|
|
||||||
|
final String agentCode = generateNextAgentCode();
|
||||||
|
final Agent agent = Agent.fromJson(client, agentCode, officeOf(client, command), command);
|
||||||
|
agent.setGlAccount(glAccountOf(command, null));
|
||||||
|
|
||||||
|
addGuarantorsFromCommand(agent, command);
|
||||||
|
|
||||||
|
this.agentRepository.saveAndFlush(agent);
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder() //
|
||||||
|
.withCommandId(command.commandId()) //
|
||||||
|
.withEntityId(agent.getId()) //
|
||||||
|
.build();
|
||||||
|
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||||
|
handleAgentDataIntegrityIssues(dve.getMostSpecificCause(), dve);
|
||||||
|
return CommandProcessingResult.empty();
|
||||||
|
} catch (final PersistenceException dve) {
|
||||||
|
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
|
||||||
|
handleAgentDataIntegrityIssues(throwable, dve);
|
||||||
|
return CommandProcessingResult.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public CommandProcessingResult updateAgent(final Long agentId, final JsonCommand command) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
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");
|
||||||
|
refuseIfAlreadyAnAgent(newClientId, agentId);
|
||||||
|
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.isChangeInLongParameterNamed("glAccountId", agentForUpdate.glAccountId())) {
|
||||||
|
agentForUpdate.setGlAccount(glAccountOf(command, agentForUpdate.getId()));
|
||||||
|
changesOnly.put("glAccountId", agentForUpdate.glAccountId());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.parameterExists("guarantors")) {
|
||||||
|
agentForUpdate.resetGuarantors();
|
||||||
|
addGuarantorsFromCommand(agentForUpdate, command);
|
||||||
|
changesOnly.put("guarantors", command.jsonFragment("guarantors"));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.agentRepository.saveAndFlush(agentForUpdate);
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(agentId).with(changesOnly)
|
||||||
|
.build();
|
||||||
|
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||||
|
handleAgentDataIntegrityIssues(dve.getMostSpecificCause(), dve);
|
||||||
|
return CommandProcessingResult.empty();
|
||||||
|
} catch (final PersistenceException dve) {
|
||||||
|
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
|
||||||
|
handleAgentDataIntegrityIssues(throwable, dve);
|
||||||
|
return CommandProcessingResult.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One client is one agent. A second code for the same person splits their collections, their
|
||||||
|
* commission and their cash-in-hand across two records paying into the same account, and every
|
||||||
|
* agent-wise report then shows them twice. A closed agent does not count - the person can be taken
|
||||||
|
* on again.
|
||||||
|
*/
|
||||||
|
private void refuseIfAlreadyAnAgent(final Long clientId, final Long forAgentId) {
|
||||||
|
for (final Agent existing : this.agentRepository.findActiveByClientId(clientId)) {
|
||||||
|
if (!existing.getId().equals(forAgentId)) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.agent.client.already.agent",
|
||||||
|
"This client is already agent " + existing.getAgentCode() + ". One client can be only one agent.",
|
||||||
|
"clientId", existing.getAgentCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 String accountNo = command.stringValueOfParameterNamed("agentSavingAccountNo");
|
||||||
|
if (accountNo == null || accountNo.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addGuarantorsFromCommand(final Agent agent, final JsonCommand command) {
|
||||||
|
if (!command.parameterExists("guarantors")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final JsonArray guarantorsArray = command.arrayOfParameterNamed("guarantors");
|
||||||
|
if (guarantorsArray == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < guarantorsArray.size(); i++) {
|
||||||
|
final JsonObject guarantorJson = guarantorsArray.get(i).getAsJsonObject();
|
||||||
|
final Long guarantorClientId = guarantorJson.get("clientId").getAsLong();
|
||||||
|
final String guarantorName = guarantorJson.get("guarantorName").getAsString();
|
||||||
|
final String address1 = guarantorJson.get("address1").getAsString();
|
||||||
|
final String address2 = guarantorJson.has("address2") && !guarantorJson.get("address2").isJsonNull()
|
||||||
|
? guarantorJson.get("address2").getAsString()
|
||||||
|
: null;
|
||||||
|
final String city = guarantorJson.get("city").getAsString();
|
||||||
|
final String pinCode = guarantorJson.get("pinCode").getAsString();
|
||||||
|
final String telephone = guarantorJson.has("telephone") && !guarantorJson.get("telephone").isJsonNull()
|
||||||
|
? guarantorJson.get("telephone").getAsString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
agent.addGuarantor(new AgentGuarantor(guarantorClientId, guarantorName, address1, address2, city, pinCode, telephone));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateNextAgentCode() {
|
||||||
|
final Long maxSuffix = this.agentRepository.retrieveMaxAgentCodeSuffix();
|
||||||
|
final long nextSuffix = (maxSuffix == null ? 0L : maxSuffix) + 1L;
|
||||||
|
return String.format("AG%04d", nextSuffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleAgentDataIntegrityIssues(final Throwable realCause, final Exception dve) {
|
||||||
|
if (realCause.getMessage() != null && realCause.getMessage().contains("agent_code")) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.agent.duplicate.agentCode", "An agent with the generated code already exists, please retry");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.error("Error occured.", dve);
|
||||||
|
throw ErrorHandler.getMappable(dve, "error.msg.agent.unknown.data.integrity.issue",
|
||||||
|
"Unknown data integrity issue with resource: " + realCause.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* 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.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;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class OrganisationAgentConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnMissingBean(AgentReadPlatformService.class)
|
||||||
|
public AgentReadPlatformService agentReadPlatformService(JdbcTemplate jdbcTemplate) {
|
||||||
|
return new AgentReadPlatformServiceImpl(jdbcTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnMissingBean(AgentWritePlatformService.class)
|
||||||
|
public AgentWritePlatformService agentWritePlatformService(AgentCommandFromApiJsonDeserializer fromApiJsonDeserializer,
|
||||||
|
AgentRepository agentRepository, ClientRepositoryWrapper clientRepositoryWrapper,
|
||||||
|
OfficeRepositoryWrapper officeRepositoryWrapper, GLAccountRepository glAccountRepository, PartyRepository partyRepository,
|
||||||
|
JdbcTemplate jdbcTemplate) {
|
||||||
|
return new AgentWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, agentRepository, clientRepositoryWrapper,
|
||||||
|
officeRepositoryWrapper, glAccountRepository, partyRepository, jdbcTemplate);
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* 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.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.organisation.commissionslab.data.CommissionSlabData;
|
||||||
|
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabReadPlatformService;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Path("/v1/dds/commissionslabs")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "DDS Commission Slabs", description = "The agent commission chart: ACCOUNT slabs (commission percentage on collection volume) and COMMISSION slabs (the override a senior agent earns on a sub-agent's commission). Append-only reference data - no update or delete API.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CommissionSlabsApiResource {
|
||||||
|
|
||||||
|
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "COMMISSIONSLAB";
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final CommissionSlabReadPlatformService readPlatformService;
|
||||||
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Retrieve DDS Commission Slabs", description = "Returns the full commission slab chart.")
|
||||||
|
public Collection<CommissionSlabData> retrieveAll() {
|
||||||
|
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||||
|
return readPlatformService.retrieveAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Add DDS Commission Slabs", description = "Mandatory fields: slabs (array of {slabType: ACCOUNT|COMMISSION, "
|
||||||
|
+ "slabFrom, slabTo, commissionPercent}). Adds new slab rows to the chart - it does not replace or edit existing rows.")
|
||||||
|
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||||
|
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||||
|
.createCommissionSlabs() //
|
||||||
|
.withJson(apiRequestBodyAsJson) //
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.organisation.commissionslab.domain;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
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 CommissionSlabRepository extends JpaRepository<CommissionSlab, Long> {
|
||||||
|
|
||||||
|
@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 = "CREATE")
|
||||||
|
public class CreateCommissionSlabsCommandHandler implements NewCommandSourceHandler {
|
||||||
|
|
||||||
|
private final CommissionSlabWritePlatformService writePlatformService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public CreateCommissionSlabsCommandHandler(final CommissionSlabWritePlatformService writePlatformService) {
|
||||||
|
this.writePlatformService = writePlatformService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
|
return this.writePlatformService.createCommissionSlabs(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.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* 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.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.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public final class CommissionSlabCommandFromApiJsonDeserializer {
|
||||||
|
|
||||||
|
// A List, not a Set: isOneOfTheseStringValues only lowercases both sides in its List overload.
|
||||||
|
private static final List<String> SUPPORTED_SLAB_TYPES = List.of("ACCOUNT", "COMMISSION");
|
||||||
|
|
||||||
|
private static final 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"));
|
||||||
|
|
||||||
|
private final FromJsonHelper fromApiJsonHelper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public CommissionSlabCommandFromApiJsonDeserializer(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_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("slabs", element)) {
|
||||||
|
v.reset().parameter("slabs").value(null).notNull();
|
||||||
|
} else {
|
||||||
|
final JsonObject topLevel = element.getAsJsonObject();
|
||||||
|
final var slabsArray = topLevel.getAsJsonArray("slabs");
|
||||||
|
v.reset().parameter("slabs").value(slabsArray).jsonArrayNotEmpty();
|
||||||
|
|
||||||
|
for (int i = 0; i < slabsArray.size(); i++) {
|
||||||
|
final JsonObject slabJson = slabsArray.get(i).getAsJsonObject();
|
||||||
|
this.fromApiJsonHelper.checkForUnsupportedParameters(slabJson, SUPPORTED_SLAB_PARAMETERS);
|
||||||
|
|
||||||
|
final String slabType = extractString(slabJson, "slabType");
|
||||||
|
v.reset().parameterAtIndexArray("slabType", i).value(slabType).notBlank()
|
||||||
|
.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()
|
||||||
|
.zeroOrPositiveAmount();
|
||||||
|
v.reset().parameterAtIndexArray("commissionPercent", i).value(extractBigDecimal(slabJson, "commissionPercent"))
|
||||||
|
.notNull().zeroOrPositiveAmount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal extractBigDecimal(final JsonObject json, final String paramName) {
|
||||||
|
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsBigDecimal() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
|
||||||
|
if (!dataValidationErrors.isEmpty()) {
|
||||||
|
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
|
||||||
|
dataValidationErrors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* 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.service;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import org.apache.fineract.organisation.commissionslab.data.CommissionSlabData;
|
||||||
|
|
||||||
|
public interface CommissionSlabReadPlatformService {
|
||||||
|
|
||||||
|
Collection<CommissionSlabData> retrieveAll();
|
||||||
|
}
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* 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.service;
|
||||||
|
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.organisation.commissionslab.data.CommissionSlabData;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
|
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CommissionSlabReadPlatformServiceImpl implements CommissionSlabReadPlatformService {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
private static final CommissionSlabMapper MAPPER = new CommissionSlabMapper();
|
||||||
|
|
||||||
|
private static final class CommissionSlabMapper implements RowMapper<CommissionSlabData> {
|
||||||
|
|
||||||
|
public String schema() {
|
||||||
|
return " s.id as id, s.slab_type as slabType, s.slab_from as slabFrom, s.slab_to as slabTo, "
|
||||||
|
+ " s.commission_percent as commissionPercent from m_dds_commission_slab s";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CommissionSlabData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||||
|
return CommissionSlabData.builder() //
|
||||||
|
.id(rs.getLong("id")) //
|
||||||
|
.slabType(rs.getString("slabType")) //
|
||||||
|
.slabFrom(rs.getBigDecimal("slabFrom")) //
|
||||||
|
.slabTo(rs.getBigDecimal("slabTo")) //
|
||||||
|
.commissionPercent(rs.getBigDecimal("commissionPercent")) //
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<CommissionSlabData> retrieveAll() {
|
||||||
|
final String sql = "select " + MAPPER.schema() + " order by s.slab_type, s.slab_from";
|
||||||
|
return this.jdbcTemplate.query(sql, MAPPER); // NOSONAR
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.organisation.commissionslab.service;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
|
||||||
|
public interface CommissionSlabWritePlatformService {
|
||||||
|
|
||||||
|
CommandProcessingResult createCommissionSlabs(JsonCommand command);
|
||||||
|
|
||||||
|
CommandProcessingResult updateCommissionSlab(Long slabId, JsonCommand command);
|
||||||
|
}
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* 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.service;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
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;
|
||||||
|
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.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;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements CommissionSlabWritePlatformService {
|
||||||
|
|
||||||
|
private final CommissionSlabCommandFromApiJsonDeserializer fromApiJsonDeserializer;
|
||||||
|
private final CommissionSlabRepository commissionSlabRepository;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public CommandProcessingResult createCommissionSlabs(final JsonCommand command) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.fromApiJsonDeserializer.validateForCreate(command.json());
|
||||||
|
|
||||||
|
final List<CommissionSlab> slabs = new ArrayList<>();
|
||||||
|
final JsonArray slabsArray = command.arrayOfParameterNamed("slabs");
|
||||||
|
for (int i = 0; i < slabsArray.size(); i++) {
|
||||||
|
final JsonObject slabJson = slabsArray.get(i).getAsJsonObject();
|
||||||
|
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);
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder() //
|
||||||
|
.withCommandId(command.commandId()) //
|
||||||
|
.withEntityId(slabs.isEmpty() ? null : slabs.get(0).getId()) //
|
||||||
|
.build();
|
||||||
|
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||||
|
log.error("Error occured.", dve);
|
||||||
|
throw ErrorHandler.getMappable(dve, "error.msg.commissionslab.unknown.data.integrity.issue",
|
||||||
|
"Unknown data integrity issue with resource: " + ExceptionUtils.getRootCauseMessage(dve));
|
||||||
|
} catch (final PersistenceException dve) {
|
||||||
|
log.error("Error occured.", dve);
|
||||||
|
throw ErrorHandler.getMappable(dve, "error.msg.commissionslab.unknown.data.integrity.issue",
|
||||||
|
"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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.organisation.commissionslab.starter;
|
||||||
|
|
||||||
|
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
|
||||||
|
import org.apache.fineract.organisation.commissionslab.serialization.CommissionSlabCommandFromApiJsonDeserializer;
|
||||||
|
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabReadPlatformService;
|
||||||
|
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabReadPlatformServiceImpl;
|
||||||
|
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabWritePlatformService;
|
||||||
|
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabWritePlatformServiceJpaRepositoryImpl;
|
||||||
|
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 OrganisationCommissionSlabConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnMissingBean(CommissionSlabReadPlatformService.class)
|
||||||
|
public CommissionSlabReadPlatformService commissionSlabReadPlatformService(JdbcTemplate jdbcTemplate) {
|
||||||
|
return new CommissionSlabReadPlatformServiceImpl(jdbcTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnMissingBean(CommissionSlabWritePlatformService.class)
|
||||||
|
public CommissionSlabWritePlatformService commissionSlabWritePlatformService(
|
||||||
|
CommissionSlabCommandFromApiJsonDeserializer fromApiJsonDeserializer, CommissionSlabRepository commissionSlabRepository) {
|
||||||
|
return new CommissionSlabWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, commissionSlabRepository);
|
||||||
|
}
|
||||||
|
}
|
||||||
+739
@@ -0,0 +1,739 @@
|
|||||||
|
/**
|
||||||
|
* 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 java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
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.data.ApiParameterError;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
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.dds.party.domain.Party;
|
||||||
|
import org.apache.fineract.portfolio.dds.voucher.DdsVoucherScope;
|
||||||
|
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";
|
||||||
|
|
||||||
|
/** What DDS customers are owed on accounts that are DDS's own rather than savings accounts. Set by 0516. */
|
||||||
|
public static final String DEPOSIT_LIABILITY = "dds-deposit-liability-account";
|
||||||
|
|
||||||
|
/** What interest on DDS's own accounts costs the bank. Set by 0518. */
|
||||||
|
public static final String INTEREST_EXPENSE = "dds-interest-expense-account";
|
||||||
|
|
||||||
|
private static final String ORGANISATION_CURRENCY_SQL = "select code from m_organisation_currency order by id limit 1";
|
||||||
|
|
||||||
|
private static final String PRODUCT_CURRENCY_SQL = "select currency_code from m_savings_product where id = ?";
|
||||||
|
|
||||||
|
private static final String CONFIGURED_ACCOUNT_SQL = "select value from c_configuration where name = ? and enabled = true";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The accounts money can be parked in for a customer with no account here. Every bank names its
|
||||||
|
* Sundry account differently, so none is assumed: every open posting liability account is offered
|
||||||
|
* and the clerk searches it by code or name and picks their bank's own. Liability, because the bank
|
||||||
|
* now owes the money - an income or expense account would book it as the bank's own. Posting,
|
||||||
|
* because a header cannot take an entry. And never an account whose balance is meant to equal the
|
||||||
|
* sum of customer or agent accounts - DDS Customer Deposits, a savings or deposit product's control
|
||||||
|
* account, a current-account control, an agent's own cash account - since parked money in any of
|
||||||
|
* those would leave it no longer adding up.
|
||||||
|
*/
|
||||||
|
private static final String SUNDRY_ACCOUNTS_SQL = "select g.id as \"id\", g.gl_code as \"glCode\", g.name as \"name\" "
|
||||||
|
+ "from acc_gl_account g "
|
||||||
|
+ "where g.classification_enum = 2 and g.account_usage = 1 and g.disabled = false "
|
||||||
|
+ "and g.id not in (select m.gl_account_id from acc_product_mapping m where m.product_type = 2 "
|
||||||
|
+ "and m.financial_account_type = 2 and m.gl_account_id is not null) "
|
||||||
|
+ "and g.id not in (select a.gl_account_id from m_dds_agent a where a.gl_account_id is not null) "
|
||||||
|
+ "and g.id not in (select c.value from c_configuration c where c.name in ('dds-deposit-liability-account', "
|
||||||
|
+ "'dds-current-account-control-account', 'dds-agent-cash-account') and c.value is not null) "
|
||||||
|
+ "order by g.gl_code";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
// Oldest first. A current product that shares its id with a savings product writes its mapping
|
||||||
|
// under the same product_type, and the two sets of rows cannot be told apart; the savings product's
|
||||||
|
// were there first, so the first row is the savings product's rather than whichever the database
|
||||||
|
// happens to return. See 0519 for the current-account side of the same collision.
|
||||||
|
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 order by id";
|
||||||
|
|
||||||
|
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;
|
||||||
|
/** Who a current-account deposit row is recorded as made by - see payOutToCurrentAccount. */
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final DdsVoucherService ddsVoucherService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a DDS operation and raises one voucher, in the DDS series, for everything it moved.
|
||||||
|
* Every service that moves money reaches the ledger through here, so the voucher comes with it.
|
||||||
|
*/
|
||||||
|
public <T> T withVoucher(final String voucherType, final String referenceType, final Long referenceId,
|
||||||
|
final java.util.function.Supplier<T> operation) {
|
||||||
|
return this.ddsVoucherService.raise(voucherType, referenceType, referenceId, operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A voucher side for a move the ledger does not show: two DDS accounts both sit on DDS Customer
|
||||||
|
* Deposits, so a transfer between them writes no leg, yet the slip still has to name both.
|
||||||
|
*/
|
||||||
|
public void voucherSideOnDeposits(final String entryType, final Party party, final BigDecimal amount, final String description) {
|
||||||
|
DdsVoucherScope.addLine(new DdsVoucherScope.Line(entryType, configured(DEPOSIT_LIABILITY).getId(),
|
||||||
|
party.getPartyCode() + " · " + party.getPartyName(), party.getAccountNo(), amount, description));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands the customers of one application their money from the cashier's DDS receipts, in one entry.
|
||||||
|
*
|
||||||
|
* The cash is already in the bank: the cashier took it from the agent and credited the agent's GL
|
||||||
|
* account. What moves here is who it belongs to - off the agent's account and onto what the bank
|
||||||
|
* owes its DDS customers - so the debit is the very account the receipts credited, never looked up
|
||||||
|
* afresh. One pair for the whole application rather than one per customer: each customer's share is
|
||||||
|
* on their own DDS account, and a thousand identical pairs would say nothing the total does not.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* DR the account the receipts credited the agent's account clears
|
||||||
|
* CR DDS Customer Deposits what the bank now owes the customers
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public void applyAgentReceipts(final Office office, final LocalDate date, final BigDecimal amount, final GLAccount receiptAccount,
|
||||||
|
final String transactionId, final String description) {
|
||||||
|
writePair(office, organisationCurrency(), transactionId, date, amount, receiptAccount, configured(DEPOSIT_LIABILITY), description,
|
||||||
|
null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same hand-on for a party still on a savings account from before DDS kept its own: credited on
|
||||||
|
* that account, as a collection always was, with the pair on the account's own control.
|
||||||
|
*/
|
||||||
|
public void applyAgentReceiptToSavings(final Party party, final LocalDate date, final BigDecimal amount,
|
||||||
|
final GLAccount receiptAccount, final String description) {
|
||||||
|
final SavingsAccount account = load(party.getSavingsAccountId());
|
||||||
|
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
||||||
|
postPair(account, deposit, date, amount, receiptAccount, controlAccountOf(account), description);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// A party's account, wherever it is kept.
|
||||||
|
//
|
||||||
|
// A DDS account is DDS's own now (0514): the balance is on the party, and what the bank owes for it
|
||||||
|
// sits on DDS Customer Deposits. A party opened before that still has a savings account behind it.
|
||||||
|
// Each movement below asks which of the two this party has and books it there - the same entry in
|
||||||
|
// the same direction either way, only the account that stands for "what the customer is owed"
|
||||||
|
// differs. Callers no longer choose; they name the party.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A collection straight onto the party's account: Dr the agent's cash, Cr what the customer is owed. */
|
||||||
|
public void collect(final Party party, final LocalDate date, final BigDecimal amount, final String description,
|
||||||
|
final GLAccount agentCashAccount) {
|
||||||
|
if (party.getSavingsAccountId() != null) {
|
||||||
|
collect(party.getSavingsAccountId(), date, amount, description, agentCashAccount);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ddsPair(party, date, amount, agentCashOr(agentCashAccount), configured(DEPOSIT_LIABILITY), description);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interest onto the party's account: Dr interest expense, Cr what the customer is owed. */
|
||||||
|
public void postInterest(final Party party, final LocalDate date, final BigDecimal amount, final String description) {
|
||||||
|
if (party.getSavingsAccountId() != null) {
|
||||||
|
postInterest(party.getSavingsAccountId(), date, amount, description);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ddsPair(party, date, amount, configured(INTEREST_EXPENSE), configured(DEPOSIT_LIABILITY), description);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interest taken back on an early closure - the exact opposite of {@link #postInterest(Party, LocalDate, BigDecimal, String)}. */
|
||||||
|
public void reverseInterest(final Party party, final LocalDate date, final BigDecimal amount, final String description) {
|
||||||
|
if (party.getSavingsAccountId() != null) {
|
||||||
|
reverseInterest(party.getSavingsAccountId(), date, amount, description);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ddsPair(party, date, amount, configured(DEPOSIT_LIABILITY), configured(INTEREST_EXPENSE), description);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The party's whole balance paid into another account of the customer's at this bank. Nothing leaves
|
||||||
|
* the bank: what it owed on the DDS account it now owes on theirs.
|
||||||
|
*/
|
||||||
|
public void payOutToOwnAccount(final Party party, final Long toSavingsAccountId, final LocalDate date, final BigDecimal amount,
|
||||||
|
final String description) {
|
||||||
|
if (party.getSavingsAccountId() != null) {
|
||||||
|
payOutToOwnAccount(party.getSavingsAccountId(), toSavingsAccountId, date, amount, description);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final SavingsAccount to = load(toSavingsAccountId);
|
||||||
|
final SavingsAccountTransaction deposit = depositWithoutAccounting(to, date, amount);
|
||||||
|
postPair(to, deposit, date, amount, configured(DEPOSIT_LIABILITY), controlAccountOf(to), description);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The party's whole balance paid into the customer's current account at this bank.
|
||||||
|
*
|
||||||
|
* Written onto the current account the way the bank's other book transfers into one are (NACH,
|
||||||
|
* clerk disbursement): a deposit row and the balances, directly, so the current-account module
|
||||||
|
* posts nothing of its own - and the pair here is the whole of the entry. Nothing leaves the bank:
|
||||||
|
* what it owed on the DDS account it now owes on the current account.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* DR what the customer was owed DDS Customer Deposits, or the party's savings control
|
||||||
|
* CR the current account's control
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public void payOutToCurrentAccount(final Party party, final Long currentAccountId, final LocalDate date, final BigDecimal amount,
|
||||||
|
final String description) {
|
||||||
|
final GLAccount owed;
|
||||||
|
final Office office;
|
||||||
|
if (party.getSavingsAccountId() != null) {
|
||||||
|
final SavingsAccount account = load(party.getSavingsAccountId());
|
||||||
|
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
||||||
|
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||||
|
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
|
||||||
|
this.savingsAccountAssembler.getPivotConfigStatus());
|
||||||
|
relockDebits(account, wasBlocked);
|
||||||
|
owed = controlAccountOf(account);
|
||||||
|
office = account.office();
|
||||||
|
} else {
|
||||||
|
owed = configured(DEPOSIT_LIABILITY);
|
||||||
|
office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice();
|
||||||
|
}
|
||||||
|
|
||||||
|
creditCurrentAccount(currentAccountId, date, amount, description);
|
||||||
|
final String transactionId = "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
writePair(office, organisationCurrency(), transactionId, date, amount, owed, currentAccountControl(currentAccountId), description,
|
||||||
|
null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String CURRENT_ACCOUNT_SQL = "select status_enum, account_balance_derived, office_id from m_current_account where id = ?";
|
||||||
|
|
||||||
|
private static final String CURRENT_DEPOSIT_SQL = "insert into m_current_account_transaction "
|
||||||
|
+ " (current_account_id, office_id, transaction_type_enum, transaction_date, amount, is_reversed, is_reversal, is_manual, "
|
||||||
|
+ " is_lien_transaction, ref_no, submitted_on_date, running_balance_derived, remarks, created_date, created_by) "
|
||||||
|
+ " values (?, ?, 1, ?, ?, false, false, false, false, ?, ?, ?, ?, current_timestamp, ?)";
|
||||||
|
|
||||||
|
private static final String CURRENT_BALANCE_SQL = "update m_current_account set account_balance_derived = account_balance_derived + ?, "
|
||||||
|
+ " total_deposits_derived = coalesce(total_deposits_derived, 0) + ? where id = ?";
|
||||||
|
|
||||||
|
private static final String CURRENT_CONTROL_SQL = "select apm.gl_account_id from m_current_account ca "
|
||||||
|
+ " join acc_product_mapping apm on apm.product_id = ca.product_id "
|
||||||
|
+ " where ca.id = ? and apm.product_type = 2 and apm.financial_account_type = 2 and apm.payment_type is null";
|
||||||
|
|
||||||
|
private static final String CURRENT_CONTROL_OVERRIDE_SQL = "select value from c_configuration "
|
||||||
|
+ " where name = 'dds-current-account-control-account' and enabled = true";
|
||||||
|
|
||||||
|
/** A deposit row and the balances on the current account, refused unless it is active. */
|
||||||
|
private void creditCurrentAccount(final Long currentAccountId, final LocalDate date, final BigDecimal amount, final String description) {
|
||||||
|
final List<java.util.Map<String, Object>> rows = this.jdbcTemplate.queryForList(CURRENT_ACCOUNT_SQL, currentAccountId); // NOSONAR
|
||||||
|
if (rows.isEmpty() || ((Number) rows.get(0).get("status_enum")).intValue() != 300) {
|
||||||
|
throw new DdsLedgerNotConfiguredException("an active current account " + currentAccountId + " to pay into");
|
||||||
|
}
|
||||||
|
final BigDecimal balance = rows.get(0).get("account_balance_derived") == null ? BigDecimal.ZERO
|
||||||
|
: (BigDecimal) rows.get(0).get("account_balance_derived");
|
||||||
|
final java.sql.Date day = java.sql.Date.valueOf(date);
|
||||||
|
final String remarks = description.length() > 490 ? description.substring(0, 490) : description;
|
||||||
|
this.jdbcTemplate.update(CURRENT_DEPOSIT_SQL, currentAccountId, rows.get(0).get("office_id"), day, amount, // NOSONAR
|
||||||
|
UUID.randomUUID().toString().replace("-", "").substring(0, 20), day, balance.add(amount), remarks,
|
||||||
|
this.context.authenticatedUser().getId());
|
||||||
|
this.jdbcTemplate.update(CURRENT_BALANCE_SQL, amount, amount, currentAccountId); // NOSONAR
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current account's control account: its product's mapping when that is the only one, and the
|
||||||
|
* setting when the product shares an id - and so its mapping rows - with a savings product.
|
||||||
|
*/
|
||||||
|
private GLAccount currentAccountControl(final Long currentAccountId) {
|
||||||
|
final List<Long> ids = this.jdbcTemplate.queryForList(CURRENT_CONTROL_SQL, Long.class, currentAccountId); // NOSONAR
|
||||||
|
if (ids.size() == 1) {
|
||||||
|
return this.glAccountRepository.findById(ids.get(0))
|
||||||
|
.orElseThrow(() -> new DdsLedgerNotConfiguredException("control account for current account " + currentAccountId));
|
||||||
|
}
|
||||||
|
final List<Long> override = this.jdbcTemplate.queryForList(CURRENT_CONTROL_OVERRIDE_SQL, Long.class); // NOSONAR
|
||||||
|
if (!override.isEmpty() && override.get(0) != null) {
|
||||||
|
return this.glAccountRepository.findById(override.get(0))
|
||||||
|
.orElseThrow(() -> new DdsLedgerNotConfiguredException("dds-current-account-control-account"));
|
||||||
|
}
|
||||||
|
throw new DdsLedgerNotConfiguredException(ids.isEmpty() ? "control account for current account " + currentAccountId
|
||||||
|
: "dds-current-account-control-account (the current product's GL mapping is shared with a savings product of the same id)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The party's balance parked in the Sundry account chosen for it - see sundryAccount. */
|
||||||
|
public void payOutToPayable(final Party party, final LocalDate date, final BigDecimal amount, final String description,
|
||||||
|
final GLAccount parkIn) {
|
||||||
|
if (party.getSavingsAccountId() != null) {
|
||||||
|
payOutToPayable(party.getSavingsAccountId(), date, amount, description, parkIn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ddsPair(party, date, amount, configured(DEPOSIT_LIABILITY), parkIn, description);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The accounts a customer's balance may be parked in, by GL code. None is chosen for the clerk. */
|
||||||
|
public List<Map<String, Object>> sundryAccounts() {
|
||||||
|
return this.jdbcTemplate.queryForList(SUNDRY_ACCOUNTS_SQL); // NOSONAR
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account to park in: the one the clerk picked, when it is on the Sundry list. There is no
|
||||||
|
* fallback - every bank's Sundry account is its own, so none is guessed. Anything off the list is
|
||||||
|
* refused - an income or asset account, or a customer's own deposits, would put the money
|
||||||
|
* somewhere nobody would think to look for it.
|
||||||
|
*/
|
||||||
|
public GLAccount sundryAccount(final Long glAccountId) {
|
||||||
|
if (glAccountId == null) {
|
||||||
|
final String message = "Pick the Sundry GL account the balance is to be parked in.";
|
||||||
|
throw new PlatformApiDataValidationException("error.msg.dds.sundry.account.required", message,
|
||||||
|
List.of(ApiParameterError.parameterError("error.msg.dds.sundry.account.required", message, "sundryGlAccountId")));
|
||||||
|
}
|
||||||
|
final boolean allowed = sundryAccounts().stream().anyMatch(row -> glAccountId.equals(((Number) row.get("id")).longValue()));
|
||||||
|
if (!allowed) {
|
||||||
|
final String message = "GL account " + glAccountId + " cannot hold a parked DDS balance - pick an open liability account that is not a customer or agent control account.";
|
||||||
|
throw new PlatformApiDataValidationException("error.msg.dds.sundry.account.invalid", message,
|
||||||
|
List.of(ApiParameterError.parameterError("error.msg.dds.sundry.account.invalid", message, "sundryGlAccountId")));
|
||||||
|
}
|
||||||
|
return this.glAccountRepository.findById(glAccountId).orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Money moved from one party's account to another's.
|
||||||
|
*
|
||||||
|
* Two DDS accounts are both owed on DDS Customer Deposits, so the bank owes the same on the same
|
||||||
|
* account before and after and there is nothing to book - the two DDS balances are the whole
|
||||||
|
* movement. Only when one side is still a savings account does the money change ledger account.
|
||||||
|
*/
|
||||||
|
public void transfer(final Party from, final Party to, final LocalDate date, final BigDecimal amount, final String description) {
|
||||||
|
final boolean fromSavings = from.getSavingsAccountId() != null;
|
||||||
|
final boolean toSavings = to.getSavingsAccountId() != null;
|
||||||
|
if (fromSavings && toSavings) {
|
||||||
|
transfer(from.getSavingsAccountId(), to.getSavingsAccountId(), date, amount, description);
|
||||||
|
} else if (fromSavings) {
|
||||||
|
final SavingsAccount account = load(from.getSavingsAccountId());
|
||||||
|
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
||||||
|
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||||
|
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
|
||||||
|
this.savingsAccountAssembler.getPivotConfigStatus());
|
||||||
|
relockDebits(account, wasBlocked);
|
||||||
|
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account),
|
||||||
|
configured(DEPOSIT_LIABILITY), description, null, account.getId());
|
||||||
|
} else if (toSavings) {
|
||||||
|
final SavingsAccount account = load(to.getSavingsAccountId());
|
||||||
|
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
||||||
|
postPair(account, deposit, date, amount, configured(DEPOSIT_LIABILITY), controlAccountOf(account), description);
|
||||||
|
} else {
|
||||||
|
// Both on DDS Customer Deposits: nothing to book, but the slip still names who paid whom.
|
||||||
|
voucherSideOnDeposits("DR", from, amount, description);
|
||||||
|
voucherSideOnDeposits("CR", to, amount, description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One pair for a party with no savings account: at the party's own branch, in the bank's currency. */
|
||||||
|
private void ddsPair(final Party party, final LocalDate date, final BigDecimal amount, final GLAccount debit, final GLAccount credit,
|
||||||
|
final String description) {
|
||||||
|
final Office office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice();
|
||||||
|
final String transactionId = "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
writePair(office, organisationCurrency(), transactionId, date, amount, debit, credit, description, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
*/
|
||||||
|
private void payOutToPayable(final Long savingsAccountId, final LocalDate date, final BigDecimal amount, final String description,
|
||||||
|
final GLAccount parkIn) {
|
||||||
|
final SavingsAccount account = load(savingsAccountId);
|
||||||
|
|
||||||
|
// DDS's own movement, so the debit block comes off for it and goes straight back on.
|
||||||
|
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
||||||
|
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||||
|
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
|
||||||
|
this.savingsAccountAssembler.getPivotConfigStatus());
|
||||||
|
relockDebits(account, wasBlocked);
|
||||||
|
|
||||||
|
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), parkIn,
|
||||||
|
description, null, account.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
writePair(office, currencyCode, transactionId, date, amount, debit, credit, description, savingsTransactionId, savingsAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writePair(final Office office, final String currencyCode, final String transactionId, final LocalDate date,
|
||||||
|
final BigDecimal amount, final GLAccount debit, final GLAccount credit, final String description,
|
||||||
|
final Long savingsTransactionId, final Long savingsAccountId) {
|
||||||
|
// Noted for the voucher of the operation this leg belongs to - see DdsVoucherService.
|
||||||
|
DdsVoucherScope.recordLedgerTransaction(transactionId);
|
||||||
|
for (final JournalEntryType type : List.of(JournalEntryType.DEBIT, JournalEntryType.CREDIT)) {
|
||||||
|
this.journalEntryRepository.saveAndFlush(JournalEntry.createNew(office, null,
|
||||||
|
JournalEntryType.DEBIT.equals(type) ? debit : credit, currencyCode, transactionId, false, date, type, amount,
|
||||||
|
description, PortfolioProductType.SAVING.getValue(), savingsAccountId, null, null, savingsTransactionId, null, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bank's own currency, for an entry that belongs to no savings product. */
|
||||||
|
private String organisationCurrency() {
|
||||||
|
final List<String> codes = this.jdbcTemplate.queryForList(ORGANISATION_CURRENCY_SQL, String.class); // NOSONAR
|
||||||
|
if (codes.isEmpty()) {
|
||||||
|
throw new DdsLedgerNotConfiguredException("organisation currency");
|
||||||
|
}
|
||||||
|
return codes.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read off the product rather than assumed, so a bank on another currency is not silently wrong. */
|
||||||
|
private String currencyOf(final Long productId) {
|
||||||
|
final List<String> codes = this.jdbcTemplate.queryForList(PRODUCT_CURRENCY_SQL, String.class, productId); // NOSONAR
|
||||||
|
if (codes.isEmpty()) {
|
||||||
|
throw new DdsLedgerNotConfiguredException("currency for savings product " + productId);
|
||||||
|
}
|
||||||
|
return codes.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An account off the savings product's own mapping, so this stays right per product. */
|
||||||
|
private GLAccount productAccount(final Long productId, final int financialAccountType) {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+450
@@ -0,0 +1,450 @@
|
|||||||
|
/**
|
||||||
|
* 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.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import jakarta.persistence.PersistenceContext;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.dds.voucher.DdsVoucherScope;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUserRepository;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raises the voucher for a DDS operation in the bank's own voucher register.
|
||||||
|
*
|
||||||
|
* Nothing about vouchers is kept by DDS itself: the number, the register and its lines are the ones
|
||||||
|
* every other module writes through {@link VoucherService}, in the same PREFIX/DDMMYYYY/NNNN form -
|
||||||
|
* only the prefix is DDS's own, so its slips read DDS/29092026/0001 and run as a series of their own.
|
||||||
|
*
|
||||||
|
* An operation is wrapped in {@link #raise}. Its ledger legs and passbook lines are noted as they are
|
||||||
|
* written (see {@link DdsVoucherScope}); when it finishes, one voucher is raised from the legs it
|
||||||
|
* actually wrote, and its number stamped on the passbook lines and on any savings entry it made. A
|
||||||
|
* wrap inside another adds to the outer one, so an early closure that also brings interest up to date
|
||||||
|
* is one slip, not two.
|
||||||
|
*
|
||||||
|
* The voucher is raised in the operation's own transaction, so an operation that rolls back - one held
|
||||||
|
* for a checker's approval, say - leaves no voucher behind, and the number is taken only when the money
|
||||||
|
* actually moves.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DdsVoucherService {
|
||||||
|
|
||||||
|
/** The series DDS vouchers are numbered in. */
|
||||||
|
public static final String PREFIX = "DDS";
|
||||||
|
|
||||||
|
public static final String TYPE_APPLY_RECEIPT = "DDS_APPLY_RECEIPT";
|
||||||
|
public static final String TYPE_INTEREST = "DDS_INTEREST";
|
||||||
|
public static final String TYPE_COMMISSION = "DDS_COMMISSION";
|
||||||
|
public static final String TYPE_MATURITY_PAYOUT = "DDS_MATURITY_PAYOUT";
|
||||||
|
public static final String TYPE_PREMATURE = "DDS_PREMATURE";
|
||||||
|
public static final String TYPE_TRANSFER = "DDS_TRANSFER";
|
||||||
|
public static final String TYPE_TRANSACTION = "DDS_TRANSACTION";
|
||||||
|
|
||||||
|
public static final String REF_PARTY = "DDS_PARTY";
|
||||||
|
public static final String REF_BATCH_RUN = "DDS_BATCH_RUN";
|
||||||
|
public static final String REF_AGENT = "DDS_AGENT";
|
||||||
|
|
||||||
|
/** The ledger id DdsLedger gives a leg posted for one party: DDS-PT0003-0fdd3261. */
|
||||||
|
private static final Pattern PARTY_LEG = Pattern.compile("^DDS-(.+)-[0-9a-f]{8}$");
|
||||||
|
|
||||||
|
private static final String LEGS_SQL = "select je.type_enum as \"typeEnum\", je.amount, je.account_id as \"glId\", "
|
||||||
|
+ "gl.name as \"glName\", gl.gl_code as \"glCode\", je.description, je.transaction_id as \"transactionId\", "
|
||||||
|
+ "je.savings_transaction_id as \"savingsTxnId\" from acc_gl_journal_entry je "
|
||||||
|
+ "join acc_gl_account gl on gl.id = je.account_id "
|
||||||
|
+ "where je.transaction_id in (%s) and (je.reversed is null or je.reversed = false) order by je.id";
|
||||||
|
|
||||||
|
private static final String PARTY_SQL = "select party_name as \"name\", account_no as \"accountNo\" from m_dds_party "
|
||||||
|
+ "where party_code = ?";
|
||||||
|
|
||||||
|
private static final String SAVINGS_OWNER_SQL = "select sa.id as \"savingsAccountId\", sa.account_no as \"accountNo\", "
|
||||||
|
+ "c.display_name as \"name\", apm.gl_account_id as \"controlGlId\" from m_savings_account_transaction t "
|
||||||
|
+ "join m_savings_account sa on sa.id = t.savings_account_id left join m_client c on c.id = sa.client_id "
|
||||||
|
+ "left join acc_product_mapping apm on apm.product_id = sa.product_id and apm.product_type = 2 "
|
||||||
|
+ "and apm.financial_account_type = 2 where t.id = ? order by apm.id limit 1";
|
||||||
|
|
||||||
|
private static final String SYSTEM_USER_SQL = "select id from m_appuser where username = 'system' and is_deleted = false limit 1";
|
||||||
|
|
||||||
|
/** How a voucher held for an entry awaiting approval is tied to that entry: DDS_COMMAND:{commandId}. */
|
||||||
|
public static final String HELD_FOR_COMMAND = "DDS_COMMAND:";
|
||||||
|
|
||||||
|
/** The entry being approved on this thread, so the voucher held for it is the one used. */
|
||||||
|
private static final ThreadLocal<Long> APPROVING_COMMAND = new ThreadLocal<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A voucher held for an entry that is waiting on a checker and has not been used yet. Held while
|
||||||
|
* the entry is pending (2), or just approved (1) for a posting run that finishes in the background;
|
||||||
|
* never one whose entry was rejected (3).
|
||||||
|
*/
|
||||||
|
private static final String HELD_VOUCHER_SQL = "select v.voucher_no from m_voucher v "
|
||||||
|
+ "join m_portfolio_command_source c on v.reference_key = 'DDS_COMMAND:' || c.id and c.status in (1, 2) "
|
||||||
|
+ "where v.voucher_no like 'DDS/%' and not exists (select 1 from m_voucher_line l where l.voucher_no = v.voucher_no) "
|
||||||
|
+ "and v.voucher_type = ? and v.reference_type = ? and v.reference_id = ? "
|
||||||
|
+ "order by (v.amount = ?) desc, v.id limit 1";
|
||||||
|
|
||||||
|
/** The last voucher raised on this thread - read by the approvals screen to show what an approval produced. */
|
||||||
|
private static final ThreadLocal<String> LAST_ISSUED = new ThreadLocal<>();
|
||||||
|
|
||||||
|
private final VoucherService voucherService;
|
||||||
|
|
||||||
|
/** Flushed before the voucher is raised, so every passbook line the operation wrote has its id. */
|
||||||
|
@PersistenceContext
|
||||||
|
private EntityManager entityManager;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final AppUserRepository appUserRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs one DDS operation and raises its voucher.
|
||||||
|
*
|
||||||
|
* @return whatever the operation returns
|
||||||
|
*/
|
||||||
|
public <T> T raise(final String voucherType, final String referenceType, final Long referenceId, final Supplier<T> operation) {
|
||||||
|
if (DdsVoucherScope.current() != null) {
|
||||||
|
// Part of a larger operation, whose voucher this becomes a part of.
|
||||||
|
return operation.get();
|
||||||
|
}
|
||||||
|
LAST_ISSUED.remove();
|
||||||
|
final DdsVoucherScope scope = DdsVoucherScope.open();
|
||||||
|
try {
|
||||||
|
// Any savings posting inside is DDS's own movement and part of this voucher, not a slip of its own.
|
||||||
|
final T result = this.voucherService.withOwnVoucher(operation);
|
||||||
|
issue(scope, voucherType, referenceType, referenceId);
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
DdsVoucherScope.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an entry for approval, numbering its voucher there and then.
|
||||||
|
*
|
||||||
|
* An entry held for a checker has moved nothing yet, but the checker approves it against a voucher
|
||||||
|
* number - so the number is taken now, with the amount the entry is for, and the entry is tied to
|
||||||
|
* it. When the entry is approved the money moves under that same number; when it is rejected the
|
||||||
|
* number is marked cancelled and never used. An entry that needs no checker is carried out at
|
||||||
|
* once and numbered as it moves, as before.
|
||||||
|
*/
|
||||||
|
public CommandProcessingResult submitHoldingVoucher(final String voucherType, final String referenceType, final Long referenceId,
|
||||||
|
final Supplier<BigDecimal> amount, final Supplier<CommandProcessingResult> submit) {
|
||||||
|
try {
|
||||||
|
return submit.get();
|
||||||
|
} catch (final RollbackTransactionNotApprovedException held) {
|
||||||
|
final Long commandId = held.getResult().getCommandId();
|
||||||
|
final AppUser user = currentUser();
|
||||||
|
if (voucherType != null && commandId != null && user != null) {
|
||||||
|
final BigDecimal value = amount.get();
|
||||||
|
this.voucherService.createVoucher(voucherType, referenceType, referenceId, HELD_FOR_COMMAND + commandId,
|
||||||
|
value == null ? BigDecimal.ZERO : value, user, PREFIX);
|
||||||
|
}
|
||||||
|
throw held;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs an approval so the voucher held for that entry is the one its money moves under. */
|
||||||
|
public <T> T approving(final Long commandId, final Supplier<T> approval) {
|
||||||
|
APPROVING_COMMAND.set(commandId);
|
||||||
|
try {
|
||||||
|
return approval.get();
|
||||||
|
} finally {
|
||||||
|
APPROVING_COMMAND.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks the voucher held for a rejected entry cancelled, so it is never used. */
|
||||||
|
public void cancelHeld(final Long commandId) {
|
||||||
|
this.jdbcTemplate.update("update m_voucher set reference_key = reference_key || ':REJECTED' where reference_key = ?", // NOSONAR
|
||||||
|
HELD_FOR_COMMAND + commandId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String heldVoucher(final String voucherType, final String referenceType, final Long referenceId, final BigDecimal total) {
|
||||||
|
final Long approving = APPROVING_COMMAND.get();
|
||||||
|
if (approving != null) {
|
||||||
|
final List<String> exact = this.jdbcTemplate.queryForList( // NOSONAR
|
||||||
|
"select voucher_no from m_voucher v where reference_key = ? and not exists "
|
||||||
|
+ "(select 1 from m_voucher_line l where l.voucher_no = v.voucher_no) order by id limit 1",
|
||||||
|
String.class, HELD_FOR_COMMAND + approving);
|
||||||
|
if (!exact.isEmpty()) {
|
||||||
|
return exact.get(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (referenceType == null || referenceId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Approved from elsewhere - the Checker Inbox - or a posting run finishing in the background.
|
||||||
|
final List<String> found = this.jdbcTemplate.queryForList(HELD_VOUCHER_SQL, String.class, voucherType, referenceType, // NOSONAR
|
||||||
|
referenceId, total);
|
||||||
|
return found.isEmpty() ? null : found.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a payout or early closure of this account will move: its balance, with interest worked out but not posted. */
|
||||||
|
public BigDecimal amountHeldBy(final Long partyId) {
|
||||||
|
final List<BigDecimal> found = this.jdbcTemplate.queryForList( // NOSONAR
|
||||||
|
"select current_balance + coalesce(interest_calc, 0) from m_dds_party where id = ?", BigDecimal.class, partyId);
|
||||||
|
return found.isEmpty() ? null : found.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The voucher a posting run raises, by its job type - the same types the run itself uses. */
|
||||||
|
public String voucherTypeOfRun(final Long batchRunId) {
|
||||||
|
final List<String> found = this.jdbcTemplate.queryForList("select job_type from m_dds_batch_run where id = ?", String.class, // NOSONAR
|
||||||
|
batchRunId);
|
||||||
|
final String jobType = found.isEmpty() ? "" : found.get(0);
|
||||||
|
return switch (jobType) {
|
||||||
|
case "COMMISSION_POSTING" -> TYPE_COMMISSION;
|
||||||
|
case "MATURITY_AUTO_CLOSE" -> TYPE_MATURITY_PAYOUT;
|
||||||
|
case "INTEREST_POSTING", "OD_INTEREST_POSTING" -> TYPE_INTEREST;
|
||||||
|
// A calculation run moves no money, so no voucher is held for it.
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a posting run will move. */
|
||||||
|
public BigDecimal amountOfRun(final Long batchRunId) {
|
||||||
|
final List<BigDecimal> found = this.jdbcTemplate.queryForList("select total_amount from m_dds_batch_run where id = ?", // NOSONAR
|
||||||
|
BigDecimal.class, batchRunId);
|
||||||
|
return found.isEmpty() ? null : found.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What an agent's day will apply: every entry of theirs still waiting to be settled. */
|
||||||
|
public BigDecimal amountPendingFor(final Long agentId) {
|
||||||
|
final List<BigDecimal> found = this.jdbcTemplate.queryForList( // NOSONAR
|
||||||
|
"select coalesce(sum(amount), 0) from m_dds_collection_entry where agent_id = ? and status = 'PENDING'", BigDecimal.class,
|
||||||
|
agentId);
|
||||||
|
return found.isEmpty() ? null : found.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A number read from a request body, or null. */
|
||||||
|
public static BigDecimal numberIn(final String json, final String name) {
|
||||||
|
final JsonObject body = bodyOf(json);
|
||||||
|
try {
|
||||||
|
return body.has(name) && !body.get(name).isJsonNull() ? body.get(name).getAsBigDecimal() : null;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A whole number read from a request body, or null. */
|
||||||
|
public static Long idIn(final String json, final String name) {
|
||||||
|
final JsonObject body = bodyOf(json);
|
||||||
|
try {
|
||||||
|
return body.has(name) && !body.get(name).isJsonNull() ? body.get(name).getAsLong() : null;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject bodyOf(final String json) {
|
||||||
|
try {
|
||||||
|
final JsonElement element = json == null ? null : JsonParser.parseString(json);
|
||||||
|
return element != null && element.isJsonObject() ? element.getAsJsonObject() : new JsonObject();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return new JsonObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The voucher the last operation on this thread raised, or null, clearing it. */
|
||||||
|
public String takeLastIssued() {
|
||||||
|
final String voucherNo = LAST_ISSUED.get();
|
||||||
|
LAST_ISSUED.remove();
|
||||||
|
return voucherNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** As {@link #raise}, for an operation that returns nothing. */
|
||||||
|
public void raise(final String voucherType, final String referenceType, final Long referenceId, final Runnable operation) {
|
||||||
|
raise(voucherType, referenceType, referenceId, () -> {
|
||||||
|
operation.run();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void issue(final DdsVoucherScope scope, final String voucherType, final String referenceType, final Long referenceId) {
|
||||||
|
// A passbook line saved after the operation's last ledger write would otherwise still be waiting
|
||||||
|
// to be inserted - noted in the scope only once it is - and would miss its voucher number.
|
||||||
|
try {
|
||||||
|
this.entityManager.flush();
|
||||||
|
} catch (RuntimeException notInTransaction) {
|
||||||
|
// Nothing pending to write when there is no transaction to flush.
|
||||||
|
}
|
||||||
|
final Set<Long> savingsTransactions = new LinkedHashSet<>();
|
||||||
|
final List<VoucherLine> lines = linesFromLedger(scope.getLedgerTransactionIds(), savingsTransactions);
|
||||||
|
for (final DdsVoucherScope.Line extra : scope.getExtraLines()) {
|
||||||
|
final VoucherLine line = new VoucherLine(extra.entryType(), extra.glAccountId(), null, null, extra.partyName(),
|
||||||
|
extra.accountNo(), extra.amount(), null);
|
||||||
|
lines.add(extra.description() == null ? line : line.withDescription(extra.description()));
|
||||||
|
}
|
||||||
|
if (lines.isEmpty()) {
|
||||||
|
// Nothing moved - a calculation run, or an operation that found nothing to do.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final BigDecimal total = lines.stream().filter(VoucherLine::isDebit).map(VoucherLine::amount).reduce(BigDecimal.ZERO,
|
||||||
|
BigDecimal::add);
|
||||||
|
final String held = heldVoucher(voucherType, referenceType, referenceId, total);
|
||||||
|
final String voucherNo;
|
||||||
|
if (held != null) {
|
||||||
|
// Numbered when the entry was sent for approval, so the checker saw it; its sides are the
|
||||||
|
// legs written now that the money has moved, and its amount what actually moved.
|
||||||
|
this.voucherService.addLines(held, lines);
|
||||||
|
this.jdbcTemplate.update("update m_voucher set amount = ? where voucher_no = ?", total, held); // NOSONAR
|
||||||
|
voucherNo = held;
|
||||||
|
} else {
|
||||||
|
final AppUser user = currentUser();
|
||||||
|
if (user == null) {
|
||||||
|
throw new IllegalStateException("No user to raise the DDS voucher as - the system user is missing.");
|
||||||
|
}
|
||||||
|
voucherNo = this.voucherService.createVoucherWithLines(voucherType, referenceType, referenceId, user, PREFIX, lines);
|
||||||
|
}
|
||||||
|
LAST_ISSUED.set(voucherNo);
|
||||||
|
|
||||||
|
stamp("update m_dds_party_transaction set voucher_no = ? where id in (%s)", voucherNo, scope.getPartyTransactionIds());
|
||||||
|
stamp("update m_savings_account_transaction set voucher_no = ? where voucher_no is null and id in (%s)", voucherNo,
|
||||||
|
savingsTransactions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The voucher's sides, read from the legs the operation wrote.
|
||||||
|
*
|
||||||
|
* A leg on a customer's own account - a DDS account, or a savings account the money was paid into -
|
||||||
|
* is named after the customer and their account, one line each, since the slip has to show who was
|
||||||
|
* paid what. Every other leg - interest expense, commission expense, a Sundry account - is an
|
||||||
|
* account of the bank's, and its legs are added up into one line per side, so a posting run over
|
||||||
|
* four hundred accounts shows one expense line against four hundred credits.
|
||||||
|
*/
|
||||||
|
private List<VoucherLine> linesFromLedger(final Set<String> transactionIds, final Set<Long> savingsTransactions) {
|
||||||
|
final List<VoucherLine> customerLines = new ArrayList<>();
|
||||||
|
final Map<String, BigDecimal> bankTotals = new LinkedHashMap<>();
|
||||||
|
final Map<String, Map<String, Object>> bankAccounts = new LinkedHashMap<>();
|
||||||
|
if (transactionIds.isEmpty()) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
final Long depositGlId = depositLiabilityGlId();
|
||||||
|
final Map<String, Map<String, Object>> parties = new LinkedHashMap<>();
|
||||||
|
for (final Map<String, Object> leg : this.jdbcTemplate.queryForList(String.format(LEGS_SQL, placeholders(transactionIds)), // NOSONAR
|
||||||
|
transactionIds.toArray())) {
|
||||||
|
final boolean debit = ((Number) leg.get("typeEnum")).intValue() == 2;
|
||||||
|
final String side = debit ? VoucherService.ENTRY_DEBIT : VoucherService.ENTRY_CREDIT;
|
||||||
|
final BigDecimal amount = (BigDecimal) leg.get("amount");
|
||||||
|
final Long glId = ((Number) leg.get("glId")).longValue();
|
||||||
|
final String description = (String) leg.get("description");
|
||||||
|
|
||||||
|
final Long savingsTxnId = leg.get("savingsTxnId") == null ? null : ((Number) leg.get("savingsTxnId")).longValue();
|
||||||
|
if (savingsTxnId != null) {
|
||||||
|
savingsTransactions.add(savingsTxnId);
|
||||||
|
final Map<String, Object> owner = this.jdbcTemplate.queryForList(SAVINGS_OWNER_SQL, savingsTxnId).stream().findFirst() // NOSONAR
|
||||||
|
.orElse(Map.of());
|
||||||
|
final Object controlGl = owner.get("controlGlId");
|
||||||
|
if (controlGl != null && glId.equals(((Number) controlGl).longValue())) {
|
||||||
|
final Long savingsAccountId = ((Number) owner.get("savingsAccountId")).longValue();
|
||||||
|
final VoucherLine line = debit
|
||||||
|
? VoucherLine.debitSavings(savingsAccountId, (String) owner.get("name"), (String) owner.get("accountNo"), amount)
|
||||||
|
: VoucherLine.creditSavings(savingsAccountId, (String) owner.get("name"), (String) owner.get("accountNo"), amount);
|
||||||
|
customerLines.add(description == null ? line : line.withDescription(description));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final Matcher partyLeg = PARTY_LEG.matcher(String.valueOf(leg.get("transactionId")));
|
||||||
|
if (partyLeg.matches() && glId.equals(depositGlId)) {
|
||||||
|
final String partyCode = partyLeg.group(1);
|
||||||
|
final Map<String, Object> party = parties.computeIfAbsent(partyCode,
|
||||||
|
code -> this.jdbcTemplate.queryForList(PARTY_SQL, code).stream().findFirst().orElse(Map.of())); // NOSONAR
|
||||||
|
final String name = party.get("name") == null ? partyCode : partyCode + " · " + party.get("name");
|
||||||
|
final VoucherLine line = new VoucherLine(side, glId, null, null, name, (String) party.get("accountNo"), amount, null);
|
||||||
|
customerLines.add(description == null ? line : line.withDescription(description));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String key = side + ":" + glId;
|
||||||
|
bankTotals.merge(key, amount, BigDecimal::add);
|
||||||
|
bankAccounts.putIfAbsent(key, leg);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<VoucherLine> lines = new ArrayList<>();
|
||||||
|
// Debits first, as a slip reads: where the money came from, then where it went.
|
||||||
|
for (final String side : List.of(VoucherService.ENTRY_DEBIT, VoucherService.ENTRY_CREDIT)) {
|
||||||
|
for (final Map.Entry<String, BigDecimal> total : bankTotals.entrySet()) {
|
||||||
|
if (total.getKey().startsWith(side + ":")) {
|
||||||
|
final Map<String, Object> account = bankAccounts.get(total.getKey());
|
||||||
|
lines.add(new VoucherLine(side, ((Number) account.get("glId")).longValue(), null, null, (String) account.get("glName"),
|
||||||
|
(String) account.get("glCode"), total.getValue(), null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final VoucherLine line : customerLines) {
|
||||||
|
if (side.equals(line.entryType())) {
|
||||||
|
lines.add(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The GL that carries what DDS customers are owed - a leg on it is the customer's own. */
|
||||||
|
private Long depositLiabilityGlId() {
|
||||||
|
final List<Long> ids = this.jdbcTemplate.queryForList( // NOSONAR
|
||||||
|
"select value from c_configuration where name = 'dds-deposit-liability-account' and enabled = true", Long.class);
|
||||||
|
return ids.isEmpty() ? null : ids.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stamp(final String sql, final String voucherNo, final Collection<Long> ids) {
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final List<Object> params = new ArrayList<>();
|
||||||
|
params.add(voucherNo);
|
||||||
|
params.addAll(ids);
|
||||||
|
this.jdbcTemplate.update(String.format(sql, placeholders(ids)), params.toArray()); // NOSONAR
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Who raised it: the user at the screen, or the system user for the nightly job. */
|
||||||
|
private AppUser currentUser() {
|
||||||
|
final AppUser user = this.context.getAuthenticatedUserIfPresent();
|
||||||
|
if (user != null) {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
final List<Long> ids = this.jdbcTemplate.queryForList(SYSTEM_USER_SQL, Long.class); // NOSONAR
|
||||||
|
return ids.isEmpty() ? null : this.appUserRepository.findById(ids.get(0)).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String placeholders(final Collection<?> values) {
|
||||||
|
return String.join(",", Collections.nCopies(values.size(), "?"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.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", "interestRate",
|
||||||
|
"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);
|
||||||
|
// Mandatory: every account opened on the scheme takes this rate.
|
||||||
|
v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element))
|
||||||
|
.notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
if (this.fromApiJsonHelper.parameterExists("interestRate", element)) {
|
||||||
|
v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element))
|
||||||
|
.notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* 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.interest_rate as interestRate, "
|
||||||
|
+ " t.is_active as active, "
|
||||||
|
+ " t.created_date as createdDate, t.lastmodified_date as lastModifiedDate "
|
||||||
|
+ " from m_dds_account_type t";
|
||||||
|
}
|
||||||
|
|
||||||
|
@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")) //
|
||||||
|
.interestRate(rs.getBigDecimal("interestRate")) //
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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 org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
|
||||||
|
public interface DdsAccountTypeWritePlatformService {
|
||||||
|
|
||||||
|
CommandProcessingResult createDdsAccountType(JsonCommand command);
|
||||||
|
|
||||||
|
CommandProcessingResult updateDdsAccountType(Long accountTypeId, JsonCommand command);
|
||||||
|
}
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* 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.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.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.apache.fineract.portfolio.dds.party.domain.Party;
|
||||||
|
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
|
||||||
|
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;
|
||||||
|
private final PartyRepository partyRepository;
|
||||||
|
|
||||||
|
@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));
|
||||||
|
// The accounts on the scheme carry the code as it was before this update.
|
||||||
|
final String codeBeforeUpdate = accountType.getAccountTypeCode();
|
||||||
|
final Map<String, Object> changesOnly = accountType.update(command);
|
||||||
|
|
||||||
|
if (!changesOnly.isEmpty()) {
|
||||||
|
this.ddsAccountTypeRepository.saveAndFlush(accountType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rate on the product is the rate: every open account on it moves to the new one, and
|
||||||
|
// its maturity figure is worked out again. Interest already calculated stays as it was;
|
||||||
|
// from here on it is worked out at the new rate.
|
||||||
|
if (changesOnly.containsKey("interestRate")) {
|
||||||
|
final List<Party> openAccounts = this.partyRepository.findActiveOnSubType(codeBeforeUpdate);
|
||||||
|
for (final Party party : openAccounts) {
|
||||||
|
party.takeSchemeRate(accountType.getInterestRate());
|
||||||
|
}
|
||||||
|
this.partyRepository.saveAll(openAccounts);
|
||||||
|
changesOnly.put("accountsRepriced", openAccounts.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* 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.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;
|
||||||
|
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,
|
||||||
|
PartyRepository partyRepository) {
|
||||||
|
return new DdsAccountTypeWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, ddsAccountTypeRepository, partyRepository);
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* 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.approval.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.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
|
||||||
|
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService;
|
||||||
|
import org.apache.fineract.portfolio.dds.approval.service.DdsApprovalReadService;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The DDS approvals queue: every DDS entry waiting on a checker, in one place, with what it will do
|
||||||
|
* and - once approved - the voucher it moved under.
|
||||||
|
*
|
||||||
|
* The entries are the platform's own maker-checker entries (m_portfolio_command_source); nothing is
|
||||||
|
* queued a second way. Approving one replays it through the platform's own approval, so the money
|
||||||
|
* moves then, and only then, and the DDS voucher is raised at that moment.
|
||||||
|
*
|
||||||
|
* One rule is held here more strictly than elsewhere: whoever made an entry cannot be the one to
|
||||||
|
* approve or reject it - not even a super user, who the platform otherwise lets check their own work.
|
||||||
|
*/
|
||||||
|
@Path("/v1/dds/approvals")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "DDS Approvals", description = "DDS entries waiting on a checker - transfers, payouts, early closures, receipts applied, "
|
||||||
|
+ "postings, and changes to parties, agents and schemes.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DdsApprovalsApiResource {
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final DdsApprovalReadService readService;
|
||||||
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
|
private final DdsVoucherService ddsVoucherService;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "DDS entries by approval status", description = "status is PENDING (default), APPROVED or REJECTED; "
|
||||||
|
+ "fromDate and toDate (yyyy-MM-dd) narrow it to the days the entries were made.")
|
||||||
|
public List<Map<String, Object>> list(@QueryParam("status") @Parameter(description = "status") final String status,
|
||||||
|
@QueryParam("fromDate") @Parameter(description = "fromDate") final String fromDate,
|
||||||
|
@QueryParam("toDate") @Parameter(description = "toDate") final String toDate) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.readService.list(status, fromDate, toDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("{commandId}")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "One DDS entry, with what it will do")
|
||||||
|
public Map<String, Object> retrieveOne(@PathParam("commandId") @Parameter(description = "commandId") final Long commandId) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.readService.retrieveOne(commandId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{commandId}")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Approve or reject a DDS entry", description = "command=approve carries the entry out and returns the "
|
||||||
|
+ "voucher it moved under; command=reject leaves nothing done. Neither is open to the user who made the entry.")
|
||||||
|
public Map<String, Object> action(@PathParam("commandId") @Parameter(description = "commandId") final Long commandId,
|
||||||
|
@QueryParam("command") @Parameter(description = "command") final String commandParam) {
|
||||||
|
final AppUser user = this.context.authenticatedUser();
|
||||||
|
final Map<String, Object> entry = this.readService.pendingEntry(commandId);
|
||||||
|
|
||||||
|
final Number makerId = (Number) entry.get("makerId");
|
||||||
|
if (makerId != null && makerId.longValue() == user.getId()) {
|
||||||
|
throw new NoAuthorizationException("You made this entry, so you cannot " + ("reject".equalsIgnoreCase(commandParam) ? "reject" : "approve")
|
||||||
|
+ " it. Another user with checker rights has to.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("approve".equalsIgnoreCase(commandParam)) {
|
||||||
|
this.ddsVoucherService.takeLastIssued();
|
||||||
|
// The money moves under the voucher numbered when the entry was sent for approval.
|
||||||
|
final CommandProcessingResult result = this.ddsVoucherService.approving(commandId,
|
||||||
|
() -> this.commandsSourceWritePlatformService.approveEntry(commandId));
|
||||||
|
String voucherNo = this.ddsVoucherService.takeLastIssued();
|
||||||
|
if (voucherNo == null) {
|
||||||
|
// A posting run finishes in the background; the voucher it will use is the one held for it.
|
||||||
|
voucherNo = this.readService.heldVoucher(commandId);
|
||||||
|
}
|
||||||
|
this.readService.linkVoucher(commandId, voucherNo);
|
||||||
|
return Map.of("commandId", commandId, "status", "APPROVED", "resourceId",
|
||||||
|
result.getResourceId() == null ? "" : result.getResourceId(), "voucherNo", voucherNo == null ? "" : voucherNo);
|
||||||
|
}
|
||||||
|
if ("reject".equalsIgnoreCase(commandParam)) {
|
||||||
|
this.commandsSourceWritePlatformService.rejectEntry(commandId);
|
||||||
|
// The number held for it is cancelled, never used for anything else.
|
||||||
|
final String voucherNo = this.readService.heldVoucher(commandId);
|
||||||
|
this.ddsVoucherService.cancelHeld(commandId);
|
||||||
|
return Map.of("commandId", commandId, "status", "REJECTED", "voucherNo", voucherNo == null ? "" : voucherNo);
|
||||||
|
}
|
||||||
|
throw new UnrecognizedQueryParamException("command", commandParam);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user