diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java index fd89de486..edf889588 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java @@ -165,12 +165,24 @@ public class CommandSourceService { || "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) || "RECURRINGDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) || "USER".equalsIgnoreCase(commandSource.getEntityName()) - || "ROLE".equalsIgnoreCase(commandSource.getEntityName())) { + || "ROLE".equalsIgnoreCase(commandSource.getEntityName()) + || isDdsEntity(commandSource.getEntityName())) { return false; } 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 sanitizeKeys) { if (sanitizeKeys == null || sanitizeKeys.isEmpty()) { return; diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index 8da153095..9da40292e 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -664,6 +664,167 @@ public class CommandWrapperBuilder { 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) { this.actionName = "DELETE"; this.entityName = "OFFICE"; diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java index 5406ac5b4..36fd4fb07 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java @@ -123,7 +123,9 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio AppUser appUser = this.context.authenticatedUser(); String permissionCode = commandSource.getPermissionCode(); 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(); if (maker == null) { throw new UnsupportedCommandException(permissionCode, "Maker user is missing."); diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java index a0027c154..5eb627eb8 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java @@ -74,6 +74,7 @@ public enum JobName { OUTWARD_CLEARING_BATCH_SEND("CTS Outward Clearing Batch Send"), // CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), // AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), // + POST_DDS_INTEREST("Post DDS Interest"), // CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), // SAVE_RBI_FORM_A_RETURN("Save RBI Form A Return"), // EXECUTE_RD_AUTO_DEBIT("Execute RD Auto Debit"), // diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/agent/data/AgentData.java b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/data/AgentData.java new file mode 100644 index 000000000..605c32854 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/data/AgentData.java @@ -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 guarantors; +} diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/agent/data/AgentGuarantorData.java b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/data/AgentGuarantorData.java new file mode 100644 index 000000000..75d990c96 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/data/AgentGuarantorData.java @@ -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; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/agent/domain/Agent.java b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/domain/Agent.java new file mode 100644 index 000000000..26ac6330f --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/domain/Agent.java @@ -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 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 update(final JsonCommand command) { + + final Map 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 actualChanges, final String paramName, + final BigDecimal existingValue, final Consumer setter) { + if (command.isChangeInBigDecimalParameterNamed(paramName, existingValue)) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(paramName); + actualChanges.put(paramName, newValue); + setter.accept(newValue == null ? BigDecimal.ZERO : newValue); + } + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/agent/domain/AgentGuarantor.java b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/domain/AgentGuarantor.java new file mode 100644 index 000000000..88bb1ee06 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/domain/AgentGuarantor.java @@ -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; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentCannotBeClosedException.java b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentCannotBeClosedException.java new file mode 100644 index 000000000..f0fee6d98 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentCannotBeClosedException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentCannotGuaranteeItselfException.java b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentCannotGuaranteeItselfException.java new file mode 100644 index 000000000..5abf58a90 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentCannotGuaranteeItselfException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentCommissionAccountUnusableException.java b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentCommissionAccountUnusableException.java new file mode 100644 index 000000000..415c1a1ed --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentCommissionAccountUnusableException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentGlAccountUnusableException.java b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentGlAccountUnusableException.java new file mode 100644 index 000000000..83bebd955 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentGlAccountUnusableException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentNotFoundException.java b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentNotFoundException.java new file mode 100644 index 000000000..b8de5e56d --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentNotFoundException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentSavingsAccountClientMismatchException.java b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentSavingsAccountClientMismatchException.java new file mode 100644 index 000000000..fa2b98133 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentSavingsAccountClientMismatchException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentSavingsAccountNotFoundException.java b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentSavingsAccountNotFoundException.java new file mode 100644 index 000000000..f2a8090ed --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentSavingsAccountNotFoundException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentSavingsAccountNotOperativeException.java b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentSavingsAccountNotOperativeException.java new file mode 100644 index 000000000..e01a4b502 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/exception/AgentSavingsAccountNotOperativeException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/commissionslab/data/CommissionSlabData.java b/fineract-core/src/main/java/org/apache/fineract/organisation/commissionslab/data/CommissionSlabData.java new file mode 100644 index 000000000..739e485ab --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/commissionslab/data/CommissionSlabData.java @@ -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; +} diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/commissionslab/domain/CommissionSlab.java b/fineract-core/src/main/java/org/apache/fineract/organisation/commissionslab/domain/CommissionSlab.java new file mode 100644 index 000000000..9b32c3055 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/commissionslab/domain/CommissionSlab.java @@ -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 update(final JsonCommand command) { + final Map 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; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/commissionslab/exception/CommissionSlabNotFoundException.java b/fineract-core/src/main/java/org/apache/fineract/organisation/commissionslab/exception/CommissionSlabNotFoundException.java new file mode 100644 index 000000000..ad5a12da8 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/commissionslab/exception/CommissionSlabNotFoundException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/commissionslab/exception/OverlappingCommissionSlabException.java b/fineract-core/src/main/java/org/apache/fineract/organisation/commissionslab/exception/OverlappingCommissionSlabException.java new file mode 100644 index 000000000..c1a17d838 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/commissionslab/exception/OverlappingCommissionSlabException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/data/DdsAccountTypeData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/data/DdsAccountTypeData.java new file mode 100644 index 000000000..ee7480cef --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/data/DdsAccountTypeData.java @@ -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; +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/domain/DdsAccountType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/domain/DdsAccountType.java new file mode 100644 index 000000000..6a9b383e1 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/domain/DdsAccountType.java @@ -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 update(final JsonCommand command) { + + final Map 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; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/exception/DdsAccountTypeNotFoundException.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/exception/DdsAccountTypeNotFoundException.java new file mode 100644 index 000000000..59d03fb75 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/exception/DdsAccountTypeNotFoundException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/data/BatchRunData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/data/BatchRunData.java new file mode 100644 index 000000000..06631ed9b --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/data/BatchRunData.java @@ -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 rows; +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/data/BatchRunRowData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/data/BatchRunRowData.java new file mode 100644 index 000000000..5e47f9605 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/data/BatchRunRowData.java @@ -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; +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/domain/BatchRun.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/domain/BatchRun.java new file mode 100644 index 000000000..2332e62da --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/domain/BatchRun.java @@ -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 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"; +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/domain/BatchRunRow.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/domain/BatchRunRow.java new file mode 100644 index 000000000..034daa075 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/domain/BatchRunRow.java @@ -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 { + + @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; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/exception/BatchRunAlreadyPostedException.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/exception/BatchRunAlreadyPostedException.java new file mode 100644 index 000000000..c777aff38 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/exception/BatchRunAlreadyPostedException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/exception/BatchRunNotFoundException.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/exception/BatchRunNotFoundException.java new file mode 100644 index 000000000..24823a5d1 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/exception/BatchRunNotFoundException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/exception/NoCommissionSlabException.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/exception/NoCommissionSlabException.java new file mode 100644 index 000000000..efd502e1a --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/exception/NoCommissionSlabException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/calculation/DdsCommissionCalculation.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/calculation/DdsCommissionCalculation.java new file mode 100644 index 000000000..9712bfac4 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/calculation/DdsCommissionCalculation.java @@ -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. + * + *
+ *   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
+ * 
+ * + * 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. + * + *

+ * 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; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/calculation/DdsInterestCalculation.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/calculation/DdsInterestCalculation.java new file mode 100644 index 000000000..bd0595411 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/calculation/DdsInterestCalculation.java @@ -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 daily product: 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. + * + *

+ * 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 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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/calculation/DdsSchedule.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/calculation/DdsSchedule.java new file mode 100644 index 000000000..b1ea57796 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/calculation/DdsSchedule.java @@ -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(); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/data/AgentCollectionSummaryData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/data/AgentCollectionSummaryData.java new file mode 100644 index 000000000..61f35367f --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/data/AgentCollectionSummaryData.java @@ -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 entries; +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/data/CollectionEntryData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/data/CollectionEntryData.java new file mode 100644 index 000000000..9a0386dee --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/data/CollectionEntryData.java @@ -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; +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/domain/CollectionEntry.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/domain/CollectionEntry.java new file mode 100644 index 000000000..7f10c5aee --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/domain/CollectionEntry.java @@ -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; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/exception/CollectionEntryNotFoundException.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/exception/CollectionEntryNotFoundException.java new file mode 100644 index 000000000..7c4fca3a3 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/exception/CollectionEntryNotFoundException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/exception/CollectionEntryNotPendingException.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/exception/CollectionEntryNotPendingException.java new file mode 100644 index 000000000..b41c9a13f --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/exception/CollectionEntryNotPendingException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/exception/NothingToSettleException.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/exception/NothingToSettleException.java new file mode 100644 index 000000000..91cf52671 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/exception/NothingToSettleException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/data/PartyData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/data/PartyData.java new file mode 100644 index 000000000..27aeb44d3 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/data/PartyData.java @@ -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 nominees; +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/data/PartyNomineeData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/data/PartyNomineeData.java new file mode 100644 index 000000000..8c0eaee8c --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/data/PartyNomineeData.java @@ -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; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/domain/Party.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/domain/Party.java new file mode 100644 index 000000000..25b269baa --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/domain/Party.java @@ -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 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 update(final JsonCommand command) { + + final Map 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 actualChanges, final String paramName, + final String existingValue, final Consumer setter) { + if (command.isChangeInStringParameterNamed(paramName, existingValue)) { + final String newValue = command.stringValueOfParameterNamed(paramName); + actualChanges.put(paramName, newValue); + setter.accept(newValue); + } + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/domain/PartyNominee.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/domain/PartyNominee.java new file mode 100644 index 000000000..580bcc557 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/domain/PartyNominee.java @@ -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)); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyAlreadyClosedException.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyAlreadyClosedException.java new file mode 100644 index 000000000..19adf89e2 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyAlreadyClosedException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyHasBalanceException.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyHasBalanceException.java new file mode 100644 index 000000000..62db2cd0b --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyHasBalanceException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyNotFoundException.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyNotFoundException.java new file mode 100644 index 000000000..4c2bf81dd --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyNotFoundException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartySavingsAccountClientMismatchException.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartySavingsAccountClientMismatchException.java new file mode 100644 index 000000000..a20390d11 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartySavingsAccountClientMismatchException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartySavingsAccountNotFoundException.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartySavingsAccountNotFoundException.java new file mode 100644 index 000000000..6c029c665 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartySavingsAccountNotFoundException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartySubTypeNotActiveException.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartySubTypeNotActiveException.java new file mode 100644 index 000000000..0b77161ba --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartySubTypeNotActiveException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/report/data/DdsReportData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/report/data/DdsReportData.java new file mode 100644 index 000000000..0142fee0c --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/report/data/DdsReportData.java @@ -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 columns; + private final List> rows; +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/data/PartyTransactionData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/data/PartyTransactionData.java new file mode 100644 index 000000000..d20ae4235 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/data/PartyTransactionData.java @@ -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; +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/domain/DdsTransactionMode.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/domain/DdsTransactionMode.java new file mode 100644 index 000000000..177fe9507 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/domain/DdsTransactionMode.java @@ -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 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*")); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/domain/PartyTransaction.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/domain/PartyTransaction.java new file mode 100644 index 000000000..9b4bb3322 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/domain/PartyTransaction.java @@ -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()); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/exception/InsufficientAgentBalanceException.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/exception/InsufficientAgentBalanceException.java new file mode 100644 index 000000000..0686b4480 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/exception/InsufficientAgentBalanceException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/exception/InsufficientPartyBalanceException.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/exception/InsufficientPartyBalanceException.java new file mode 100644 index 000000000..da920dcbd --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/exception/InsufficientPartyBalanceException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/exception/PartyNotCollectedByAgentException.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/exception/PartyNotCollectedByAgentException.java new file mode 100644 index 000000000..7b6c2b9a3 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/exception/PartyNotCollectedByAgentException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/exception/PartyTransactionNotFoundException.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/exception/PartyTransactionNotFoundException.java new file mode 100644 index 000000000..2ea13495b --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/exception/PartyTransactionNotFoundException.java @@ -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); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/voucher/DdsVoucherScope.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/voucher/DdsVoucherScope.java new file mode 100644 index 000000000..96552bea8 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/voucher/DdsVoucherScope.java @@ -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 CURRENT = new ThreadLocal<>(); + + private final Set ledgerTransactionIds = new LinkedHashSet<>(); + private final List partyTransactionIds = new ArrayList<>(); + private final List 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 getLedgerTransactionIds() { + return Collections.unmodifiableSet(this.ledgerTransactionIds); + } + + public List getPartyTransactionIds() { + return Collections.unmodifiableList(this.partyTransactionIds); + } + + public List getExtraLines() { + return Collections.unmodifiableList(this.extraLines); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java index 0dd86849f..9c0c9c091 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java @@ -278,6 +278,7 @@ public class SavingsApiConstants { // Additional custom fields public static final String balanceFrequencyParamName = "balanceFrequency"; public static final String accountClosedZeroBalanceInDayClosingParamName = "accountClosedZeroBalanceInDayClosing"; + public static final String productCategoryParamName = "productCategory"; // Current Product — new UI fields public static final String balanceSwapOptionParamName = "balanceSwapOption"; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductData.java index f508d3227..25ce0e0fa 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductData.java @@ -144,6 +144,7 @@ public final class SavingsProductData implements Serializable { @Getter @Setter private Boolean enableLockinPeriod; @Getter @Setter private String balanceFrequency; @Getter @Setter private Boolean accountClosedZeroBalanceInDayClosing; + @Getter @Setter private String productCategory; // New current product fields @Getter @Setter private Boolean balanceSwapOption; diff --git a/fineract-core/src/test/java/org/apache/fineract/organisation/agent/domain/AgentTest.java b/fineract-core/src/test/java/org/apache/fineract/organisation/agent/domain/AgentTest.java new file mode 100644 index 000000000..526f59132 --- /dev/null +++ b/fineract-core/src/test/java/org/apache/fineract/organisation/agent/domain/AgentTest.java @@ -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()); + } + } +} diff --git a/fineract-core/src/test/java/org/apache/fineract/portfolio/dds/DdsCommands.java b/fineract-core/src/test/java/org/apache/fineract/portfolio/dds/DdsCommands.java new file mode 100644 index 000000000..87bcf9339 --- /dev/null +++ b/fineract-core/src/test/java/org/apache/fineract/portfolio/dds/DdsCommands.java @@ -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); + } +} diff --git a/fineract-core/src/test/java/org/apache/fineract/portfolio/dds/calculation/DdsCommissionCalculationTest.java b/fineract-core/src/test/java/org/apache/fineract/portfolio/dds/calculation/DdsCommissionCalculationTest.java new file mode 100644 index 000000000..4fbdc6678 --- /dev/null +++ b/fineract-core/src/test/java/org/apache/fineract/portfolio/dds/calculation/DdsCommissionCalculationTest.java @@ -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()); + } +} diff --git a/fineract-core/src/test/java/org/apache/fineract/portfolio/dds/calculation/DdsInterestCalculationTest.java b/fineract-core/src/test/java/org/apache/fineract/portfolio/dds/calculation/DdsInterestCalculationTest.java new file mode 100644 index 000000000..f52e74fcf --- /dev/null +++ b/fineract-core/src/test/java/org/apache/fineract/portfolio/dds/calculation/DdsInterestCalculationTest.java @@ -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 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); + } + } +} diff --git a/fineract-core/src/test/java/org/apache/fineract/portfolio/dds/party/domain/PartyTest.java b/fineract-core/src/test/java/org/apache/fineract/portfolio/dds/party/domain/PartyTest.java new file mode 100644 index 000000000..aca329592 --- /dev/null +++ b/fineract-core/src/test/java/org/apache/fineract/portfolio/dds/party/domain/PartyTest.java @@ -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()); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/api/AgentsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/api/AgentsApiResource.java new file mode 100644 index 000000000..19d213f3f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/api/AgentsApiResource.java @@ -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 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/domain/AgentRepository.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/domain/AgentRepository.java new file mode 100644 index 000000000..8e3dd3121 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/domain/AgentRepository.java @@ -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 { + + /** + * 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 findByAgentCode(String agentCode); + + @Query("select a from Agent a where a.id between :agentFrom and :agentTo") + List 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 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 findActiveByClientId(@Param("clientId") Long clientId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/handler/CloseAgentCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/handler/CloseAgentCommandHandler.java new file mode 100644 index 000000000..fcaacc278 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/handler/CloseAgentCommandHandler.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/handler/CreateAgentCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/handler/CreateAgentCommandHandler.java new file mode 100644 index 000000000..6d1904226 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/handler/CreateAgentCommandHandler.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/handler/UpdateAgentCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/handler/UpdateAgentCommandHandler.java new file mode 100644 index 000000000..21fb56101 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/handler/UpdateAgentCommandHandler.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/serialization/AgentCommandFromApiJsonDeserializer.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/serialization/AgentCommandFromApiJsonDeserializer.java new file mode 100644 index 000000000..496c78e3c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/serialization/AgentCommandFromApiJsonDeserializer.java @@ -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 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 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 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>() { + + }.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS); + + final List 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>() { + + }.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_UPDATE_PARAMETERS); + + final List 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 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 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); + } + +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/service/AgentReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/service/AgentReadPlatformService.java new file mode 100644 index 000000000..383551587 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/service/AgentReadPlatformService.java @@ -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 retrieveAll(); + + AgentData retrieveOne(Long agentId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/service/AgentReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/service/AgentReadPlatformServiceImpl.java new file mode 100644 index 000000000..e6b7a235f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/service/AgentReadPlatformServiceImpl.java @@ -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 { + + 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> { + + 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 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 retrieveAll() { + final String sql = "select " + AGENT_MAPPER.schema() + " order by a.id"; + final List 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 attachGuarantors(final List agents) { + if (agents.isEmpty()) { + return agents; + } + + final String guarantorSql = "select " + GUARANTOR_MAPPER.schema() + " order by g.id"; + final List> guarantorRows = this.jdbcTemplate.query(guarantorSql, GUARANTOR_MAPPER); // NOSONAR + final Map> guarantorsByAgentId = new LinkedHashMap<>(); + for (final Map.Entry row : guarantorRows) { + guarantorsByAgentId.computeIfAbsent(row.getKey(), k -> new ArrayList<>()).add(row.getValue()); + } + + final List result = new ArrayList<>(agents.size()); + for (final AgentData agent : agents) { + result.add(agent.toBuilder().guarantors(guarantorsByAgentId.getOrDefault(agent.getId(), new ArrayList<>())).build()); + } + return result; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/service/AgentWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/service/AgentWritePlatformService.java new file mode 100644 index 000000000..7725c7712 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/service/AgentWritePlatformService.java @@ -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); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/service/AgentWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/service/AgentWritePlatformServiceJpaRepositoryImpl.java new file mode 100644 index 000000000..cf0caa81a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/service/AgentWritePlatformServiceJpaRepositoryImpl.java @@ -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 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> accounts = this.jdbcTemplate.queryForList(OPEN_ACCOUNT_SQL, accountNo); // NOSONAR + if (accounts.isEmpty()) { + throw new AgentSavingsAccountNotFoundException(accountNo); + } + + final Map 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 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()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/starter/OrganisationAgentConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/starter/OrganisationAgentConfiguration.java new file mode 100644 index 000000000..4f3c81119 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/starter/OrganisationAgentConfiguration.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/api/CommissionSlabsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/api/CommissionSlabsApiResource.java new file mode 100644 index 000000000..bb9c554b7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/api/CommissionSlabsApiResource.java @@ -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 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/domain/CommissionSlabRepository.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/domain/CommissionSlabRepository.java new file mode 100644 index 000000000..7f232d21d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/domain/CommissionSlabRepository.java @@ -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 { + + @Query("select s from CommissionSlab s where s.slabType = :slabType and :amount between s.slabFrom and s.slabTo") + List findMatchingSlabs(@Param("slabType") String slabType, @Param("amount") BigDecimal amount); + + @Query("select s from CommissionSlab s where s.slabType = :slabType order by s.slabFrom") + List findByType(@Param("slabType") String slabType); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/handler/CreateCommissionSlabsCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/handler/CreateCommissionSlabsCommandHandler.java new file mode 100644 index 000000000..3227aa1a8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/handler/CreateCommissionSlabsCommandHandler.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/handler/UpdateCommissionSlabCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/handler/UpdateCommissionSlabCommandHandler.java new file mode 100644 index 000000000..9598f58b0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/handler/UpdateCommissionSlabCommandHandler.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/serialization/CommissionSlabCommandFromApiJsonDeserializer.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/serialization/CommissionSlabCommandFromApiJsonDeserializer.java new file mode 100644 index 000000000..485e8feeb --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/serialization/CommissionSlabCommandFromApiJsonDeserializer.java @@ -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 SUPPORTED_SLAB_TYPES = List.of("ACCOUNT", "COMMISSION"); + + private static final Set SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("slabs", "dateFormat", "locale")); + + private static final Set SUPPORTED_UPDATE_PARAMETERS = new HashSet<>( + Arrays.asList("slabType", "slabFrom", "slabTo", "commissionPercent", "dateFormat", "locale")); + + private static final Set 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>() { + + }.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS); + + final List 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>() { + + }.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_UPDATE_PARAMETERS); + + final List 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 dataValidationErrors) { + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", + dataValidationErrors); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/service/CommissionSlabReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/service/CommissionSlabReadPlatformService.java new file mode 100644 index 000000000..4a76d2c84 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/service/CommissionSlabReadPlatformService.java @@ -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 retrieveAll(); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/service/CommissionSlabReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/service/CommissionSlabReadPlatformServiceImpl.java new file mode 100644 index 000000000..28dca45a4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/service/CommissionSlabReadPlatformServiceImpl.java @@ -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 { + + 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 retrieveAll() { + final String sql = "select " + MAPPER.schema() + " order by s.slab_type, s.slab_from"; + return this.jdbcTemplate.query(sql, MAPPER); // NOSONAR + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/service/CommissionSlabWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/service/CommissionSlabWritePlatformService.java new file mode 100644 index 000000000..198fd0b2d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/service/CommissionSlabWritePlatformService.java @@ -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); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/service/CommissionSlabWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/service/CommissionSlabWritePlatformServiceJpaRepositoryImpl.java new file mode 100644 index 000000000..850f07d06 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/service/CommissionSlabWritePlatformServiceJpaRepositoryImpl.java @@ -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 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 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 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 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()); + } + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/starter/OrganisationCommissionSlabConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/starter/OrganisationCommissionSlabConfiguration.java new file mode 100644 index 000000000..2ef59c5c6 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/starter/OrganisationCommissionSlabConfiguration.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsLedger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsLedger.java new file mode 100644 index 000000000..1383990c9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsLedger.java @@ -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. + * + *

+ * The five movements and the entries each makes: + * + *

+ *   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
+ * 
+ * + * 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. + * + *

+ * 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 withVoucher(final String voucherType, final String referenceType, final Long referenceId, + final java.util.function.Supplier 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. + * + *

+     *   DR  the account the receipts credited       the agent's account clears
+     *   CR  DDS Customer Deposits                   what the bank now owes the customers
+     * 
+ */ + 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. + * + *
+     *   DR  what the customer was owed       DDS Customer Deposits, or the party's savings control
+     *   CR  the current account's control
+     * 
+ */ + 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> 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 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 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> 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. + * + *
+     *   DR  the account's control        the customer's balance comes down
+     *   CR  Interest on Savings          the bank's interest expense is reduced
+     * 
+ */ + 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. + * + *
+     *   DR  the account's control          the customer's balance goes
+     *   CR  Matured Deposits Payable       and the bank now owes it to them
+     * 
+ */ + 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. + * + *
+     *   DR  the product's fund source     the cash the bank already holds
+     *   CR  the account's control         what it owes the customer
+     * 
+ * + * 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 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 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 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 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 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)); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsVoucherService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsVoucherService.java new file mode 100644 index 000000000..daef9db89 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsVoucherService.java @@ -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 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 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 raise(final String voucherType, final String referenceType, final Long referenceId, final Supplier 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 amount, final Supplier 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 approving(final Long commandId, final Supplier 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 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 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 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 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 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 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 savingsTransactions = new LinkedHashSet<>(); + final List 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 linesFromLedger(final Set transactionIds, final Set savingsTransactions) { + final List customerLines = new ArrayList<>(); + final Map bankTotals = new LinkedHashMap<>(); + final Map> bankAccounts = new LinkedHashMap<>(); + if (transactionIds.isEmpty()) { + return new ArrayList<>(); + } + + final Long depositGlId = depositLiabilityGlId(); + final Map> parties = new LinkedHashMap<>(); + for (final Map 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 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 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 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 total : bankTotals.entrySet()) { + if (total.getKey().startsWith(side + ":")) { + final Map 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 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 ids) { + if (ids.isEmpty()) { + return; + } + final List 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 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(), "?")); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/exception/DdsLedgerNotConfiguredException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/exception/DdsLedgerNotConfiguredException.java new file mode 100644 index 000000000..bfa0f3751 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/exception/DdsLedgerNotConfiguredException.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/api/DdsAccountTypesApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/api/DdsAccountTypesApiResource.java new file mode 100644 index 000000000..1bdfe6a70 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/api/DdsAccountTypesApiResource.java @@ -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 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); + } + +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/domain/DdsAccountTypeRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/domain/DdsAccountTypeRepository.java new file mode 100644 index 000000000..5f2454fd8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/domain/DdsAccountTypeRepository.java @@ -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 { + + Optional 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 findDueForAutoInterestPost(); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/handler/CreateDdsAccountTypeCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/handler/CreateDdsAccountTypeCommandHandler.java new file mode 100644 index 000000000..f4cd430bc --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/handler/CreateDdsAccountTypeCommandHandler.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/handler/UpdateDdsAccountTypeCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/handler/UpdateDdsAccountTypeCommandHandler.java new file mode 100644 index 000000000..17deeccb7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/handler/UpdateDdsAccountTypeCommandHandler.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/serialization/DdsAccountTypeCommandFromApiJsonDeserializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/serialization/DdsAccountTypeCommandFromApiJsonDeserializer.java new file mode 100644 index 000000000..ffc167de7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/serialization/DdsAccountTypeCommandFromApiJsonDeserializer.java @@ -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 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 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 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>() { + + }.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 dataValidationErrors) { + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", + dataValidationErrors); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeReadPlatformService.java new file mode 100644 index 000000000..b542e9c06 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeReadPlatformService.java @@ -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 retrieveAll(); + + DdsAccountTypeData retrieveOne(Long accountTypeId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeReadPlatformServiceImpl.java new file mode 100644 index 000000000..5b07e59ad --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeReadPlatformServiceImpl.java @@ -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 { + + 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 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); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeWritePlatformService.java new file mode 100644 index 000000000..383c86030 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeWritePlatformService.java @@ -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); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeWritePlatformServiceJpaRepositoryImpl.java new file mode 100644 index 000000000..e6308f130 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeWritePlatformServiceJpaRepositoryImpl.java @@ -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 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 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()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/starter/DdsAccountTypeConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/starter/DdsAccountTypeConfiguration.java new file mode 100644 index 000000000..68623db36 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/starter/DdsAccountTypeConfiguration.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/api/DdsApprovalsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/api/DdsApprovalsApiResource.java new file mode 100644 index 000000000..5bad9d4e3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/api/DdsApprovalsApiResource.java @@ -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> 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 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 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 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/service/DdsApprovalReadService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/service/DdsApprovalReadService.java new file mode 100644 index 000000000..b8ea58fa3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/service/DdsApprovalReadService.java @@ -0,0 +1,503 @@ +/** + * 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.service; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.exception.CommandNotAwaitingApprovalException; +import org.apache.fineract.commands.exception.CommandNotFoundException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Reads the DDS entries in the platform's maker-checker register and says, in the clerk's terms, what + * each one does: which accounts, how much, where the money goes, and - once it has moved - under which + * voucher. + * + * Only the DDS actions that are actually held for a checker are listed (their permission is marked for + * maker-checker), so the list is the queue of work, not every DDS command ever logged. + */ +@Component +@RequiredArgsConstructor +public class DdsApprovalReadService { + + private static final int STATUS_PROCESSED = 1; + private static final int STATUS_AWAITING = 2; + private static final int STATUS_REJECTED = 3; + + private static final String ENTRIES_SQL = "select c.id as \"id\", c.action_name as \"action\", c.entity_name as \"entity\", " + + "c.resource_id as \"resourceId\", c.status as \"status\", c.command_as_json as \"json\", " + + "to_char(c.made_on_date_utc, 'YYYY-MM-DD HH24:MI') as \"madeOn\", to_char(c.checked_on_date_utc, 'YYYY-MM-DD HH24:MI') as \"checkedOn\", " + + "mk.id as \"makerId\", mk.username as \"maker\", ck.username as \"checker\", " + + "trim(coalesce(mk.firstname, '') || ' ' || coalesce(mk.lastname, '')) as \"makerName\", " + + "trim(coalesce(ck.firstname, '') || ' ' || coalesce(ck.lastname, '')) as \"checkerName\" " + + "from m_portfolio_command_source c " + + "join m_permission p on p.code = c.action_name || '_' || c.entity_name and p.can_maker_checker = true " + + "left join m_appuser mk on mk.id = c.maker_id left join m_appuser ck on ck.id = c.checker_id " + + "where c.entity_name in ('PARTY', 'AGENT', 'PARTYTRANSACTION', 'DDSACCOUNTTYPE', 'COMMISSIONSLAB', 'DDSBATCH', " + + "'DDSCOLLECTIONENTRY') "; + + private static final String REF_KEY_PREFIX = "DDS_COMMAND:"; + + private final JdbcTemplate jdbcTemplate; + + public List> list(final String status, final String fromDate, final String toDate) { + final StringBuilder sql = new StringBuilder(ENTRIES_SQL).append(" and c.status = ? "); + final List params = new ArrayList<>(); + params.add(statusCode(status)); + if (fromDate != null && !fromDate.isBlank()) { + sql.append(" and cast(c.made_on_date_utc as date) >= cast(? as date) "); + params.add(fromDate); + } + if (toDate != null && !toDate.isBlank()) { + sql.append(" and cast(c.made_on_date_utc as date) <= cast(? as date) "); + params.add(toDate); + } + sql.append(" order by c.id desc limit 500"); + final List> rows = new ArrayList<>(); + for (final Map entry : this.jdbcTemplate.queryForList(sql.toString(), params.toArray())) { // NOSONAR + final Map row = describe(entry); + // How many accounts it touches, as the Disbursement & Transfers list counts its entries. + row.put("entries", linesFor(row, entry).size()); + rows.add(row); + } + return rows; + } + + public Map retrieveOne(final Long commandId) { + final List> found = this.jdbcTemplate.queryForList(ENTRIES_SQL + " and c.id = ?", commandId); // NOSONAR + if (found.isEmpty()) { + throw new CommandNotFoundException(commandId); + } + final Map row = describe(found.get(0)); + row.put("description", text(jsonOf(found.get(0).get("json")), "withdrawDescription")); + row.put("lines", linesFor(row, found.get(0))); + return row; + } + + /** + * The debit and credit sides of the entry, as its voucher shows them. Once the money has moved they + * are the voucher's own lines; while it waits they are what approving it will move, worked out from + * the request and the accounts as they stand now. + */ + private List> linesFor(final Map row, final Map entry) { + final String voucherNo = (String) row.get("voucherNo"); + if (voucherNo != null) { + final List> posted = this.jdbcTemplate.queryForList( // NOSONAR + "select entry_type as \"side\", account_no as \"accountNo\", party_name as \"accountName\", " + + "case when savings_account_id is not null then 'SAVINGS' when gl_account_id is not null then 'GL' else '' end as \"type\", " + + "amount from m_voucher_line where voucher_no = ? order by line_no", + voucherNo); + if (!posted.isEmpty()) { + return posted; + } + } + final List> lines = new ArrayList<>(); + final String entity = (String) row.get("entity"); + final String action = (String) row.get("action"); + final Long resourceId = (Long) row.get("resourceId"); + final JsonObject json = jsonOf(entry.get("json")); + final BigDecimal amount = (BigDecimal) row.get("amount"); + switch (entity + ":" + action) { + case "PARTYTRANSACTION:CREATE" -> { + lines.add(partyLine("DR", text(json, "debitPartyCode"), amount)); + lines.add(partyLine("CR", text(json, "creditorPartyCode"), amount)); + } + case "PARTY:PAYOUT", "PARTY:PREMATURECLOSE", "PARTY:PREMATURECLOSEOVERRIDE" -> { + final String code = first("select party_code from m_dds_party where id = ?", resourceId); + lines.add(partyLine("DR", code, amount)); + final String toAccount = text(json, "toSavingsAccountNo"); + if (toAccount != null && !toAccount.isBlank()) { + final String owner = first("select c.display_name from m_savings_account s left join m_client c on c.id = s.client_id " + + "where s.account_no = ?", toAccount); + lines.add(line("CR", toAccount, owner, "SAVINGS", amount)); + } else { + final Long glId = longOf(json, "sundryGlAccountId"); + final Map gl = glId == null ? Map.of() + : this.jdbcTemplate.queryForList("select gl_code as \"code\", name from acc_gl_account where id = ?", glId) // NOSONAR + .stream().findFirst().orElse(Map.of()); + lines.add(line("CR", (String) gl.get("code"), (String) gl.get("name"), "GL", amount)); + } + } + case "DDSCOLLECTIONENTRY:SETTLE" -> { + final Long agentId = longOf(json, "agentId"); + final Map agentGl = agentId == null ? Map.of() + : this.jdbcTemplate.queryForList("select g.gl_code as \"code\", g.name from m_dds_agent a " // NOSONAR + + "join acc_gl_account g on g.id = a.gl_account_id where a.id = ?", agentId).stream().findFirst().orElse(Map.of()); + lines.add(line("DR", (String) agentGl.get("code"), (String) agentGl.get("name"), "GL", amount)); + if (agentId != null) { + for (final Map pending : this.jdbcTemplate.queryForList( // NOSONAR + "select p.account_no as \"accountNo\", p.party_code || ' · ' || p.party_name as \"accountName\", e.amount " + + "from m_dds_collection_entry e join m_dds_party p on p.id = e.party_id " + + "where e.agent_id = ? and e.status = 'PENDING' order by e.id", + agentId)) { + lines.add(line("CR", (String) pending.get("accountNo"), (String) pending.get("accountName"), "DDS", + (BigDecimal) pending.get("amount"))); + } + } + } + case "DDSBATCH:POST" -> { + final String expense = first("select c.name from m_dds_batch_run r join c_configuration c on c.name = " + + "case when r.job_type like 'COMMISSION%' then 'dds-agent-commission-expense-account' " + + "else 'dds-interest-expense-account' end where r.id = ?", resourceId); + final Map gl = expense == null ? Map.of() + : this.jdbcTemplate.queryForList("select g.gl_code as \"code\", g.name from c_configuration c " // NOSONAR + + "join acc_gl_account g on g.id = c.value where c.name = ?", expense).stream().findFirst().orElse(Map.of()); + lines.add(line("DR", (String) gl.get("code"), (String) gl.get("name"), "GL", amount)); + for (final Map credited : this.jdbcTemplate.queryForList( // NOSONAR + "select coalesce(p.account_no, a.agent_saving_account_no) as \"accountNo\", " + + "coalesce(p.party_code || ' · ' || p.party_name, a.agent_code || ' · ' || a.agent_name) as \"accountName\", " + + "case when p.id is null then 'SAVINGS' else 'DDS' end as \"type\", r.amount " + + "from m_dds_batch_run_row r left join m_dds_party p on p.id = r.party_id " + + "left join m_dds_agent a on a.id = r.agent_id where r.batch_run_id = ? and r.amount > 0 order by r.id", + resourceId)) { + lines.add(line("CR", (String) credited.get("accountNo"), (String) credited.get("accountName"), + (String) credited.get("type"), (BigDecimal) credited.get("amount"))); + } + } + default -> { + // A change to a party, agent or scheme moves no money and has no sides. + } + } + return lines; + } + + private Map partyLine(final String side, final String partyCode, final BigDecimal amount) { + final Map party = partyCode == null ? Map.of() + : this.jdbcTemplate.queryForList("select account_no as \"accountNo\", party_name as \"name\" from m_dds_party " // NOSONAR + + "where party_code = ?", partyCode).stream().findFirst().orElse(Map.of()); + return line(side, (String) party.get("accountNo"), partyCode == null ? null : partyCode + " · " + party.get("name"), "DDS", amount); + } + + private static Map line(final String side, final String accountNo, final String accountName, final String type, + final BigDecimal amount) { + final Map line = new LinkedHashMap<>(); + line.put("side", side); + line.put("accountNo", accountNo); + line.put("accountName", accountName); + line.put("type", type); + line.put("amount", amount); + return line; + } + + /** The entry, if it is a DDS entry still waiting on a checker. */ + public Map pendingEntry(final Long commandId) { + final List> found = this.jdbcTemplate.queryForList(ENTRIES_SQL + " and c.id = ?", commandId); // NOSONAR + if (found.isEmpty()) { + throw new CommandNotFoundException(commandId); + } + if (((Number) found.get(0).get("status")).intValue() != STATUS_AWAITING) { + throw new CommandNotAwaitingApprovalException(commandId); + } + return found.get(0); + } + + /** The voucher number held for an entry since it was sent for approval, if any. */ + public String heldVoucher(final Long commandId) { + return first("select voucher_no from m_voucher where reference_key = ? order by id desc limit 1", REF_KEY_PREFIX + commandId); + } + + /** Ties the voucher an approval raised to the entry, so the approved list can show it. */ + public void linkVoucher(final Long commandId, final String voucherNo) { + if (voucherNo != null) { + this.jdbcTemplate.update("update m_voucher set reference_key = ? where voucher_no = ? and reference_key is null", // NOSONAR + REF_KEY_PREFIX + commandId, voucherNo); + } + } + + private static int statusCode(final String status) { + if ("APPROVED".equalsIgnoreCase(status)) { + return STATUS_PROCESSED; + } + return "REJECTED".equalsIgnoreCase(status) ? STATUS_REJECTED : STATUS_AWAITING; + } + + private static String statusName(final int code) { + return switch (code) { + case STATUS_PROCESSED -> "APPROVED"; + case STATUS_REJECTED -> "REJECTED"; + case STATUS_AWAITING -> "PENDING"; + default -> "OTHER"; + }; + } + + /** One entry in the clerk's terms: what kind of work, on which accounts, for how much, and where to. */ + private Map describe(final Map entry) { + final String entity = (String) entry.get("entity"); + final String action = (String) entry.get("action"); + final Long resourceId = entry.get("resourceId") == null ? null : ((Number) entry.get("resourceId")).longValue(); + final JsonObject json = jsonOf(entry.get("json")); + final int status = ((Number) entry.get("status")).intValue(); + + final Map row = new LinkedHashMap<>(); + row.put("id", entry.get("id")); + row.put("entity", entity); + row.put("action", action); + row.put("resourceId", resourceId); + row.put("status", statusName(status)); + row.put("maker", entry.get("maker")); + row.put("makerName", entry.get("makerName")); + row.put("checkerName", entry.get("checkerName")); + row.put("makerId", entry.get("makerId")); + row.put("madeOn", entry.get("madeOn")); + row.put("checker", entry.get("checker")); + row.put("checkedOn", entry.get("checkedOn")); + + String type = entity + " " + action; + String details = ""; + BigDecimal amount = null; + switch (entity + ":" + action) { + case "PARTYTRANSACTION:CREATE" -> { + final String txnType = text(json, "transactionType"); + if ("WITHDRAW_DEPOSITE".equals(txnType)) { + type = "Transfer"; + details = party(text(json, "debitPartyCode")) + " → " + party(text(json, "creditorPartyCode")); + amount = number(json, "withdrawAmount"); + } else { + type = txnType == null ? "DDS Transaction" : txnType.replace('_', ' '); + details = agent(longOf(json, "agentId")); + } + } + case "PARTY:PAYOUT" -> { + type = "Maturity Payout & Close"; + details = partyById(resourceId) + " → " + destination(json); + amount = status == STATUS_AWAITING ? balanceOf(resourceId) : paidOut(resourceId, "MATURITY_PAYOUT"); + } + case "PARTY:PREMATURECLOSE", "PARTY:PREMATURECLOSEOVERRIDE" -> { + type = "PREMATURECLOSEOVERRIDE".equals(action) ? "Premature Close (within lock-in)" : "Premature Close"; + details = partyById(resourceId) + " → " + destination(json); + final String reason = text(json, "lockinOverrideReason"); + if (reason != null) { + details = details + " · Reason: " + reason; + } + amount = status == STATUS_AWAITING ? balanceOf(resourceId) : paidOut(resourceId, "PREMATURE_PAYOUT"); + } + case "PARTY:CLOSE" -> { + type = "Close DDS Account"; + details = partyById(resourceId); + } + case "PARTY:CREATE" -> { + type = "New DDS Account"; + details = resourceId == null ? text(json, "partyName") : partyById(resourceId); + amount = number(json, "dailyDepositAmount"); + } + case "PARTY:UPDATE" -> { + type = "Edit DDS Account"; + details = partyById(resourceId); + } + case "AGENT:CREATE", "AGENT:UPDATE", "AGENT:CLOSE" -> { + type = switch (action) { + case "CREATE" -> "New Agent"; + case "CLOSE" -> "Close Agent"; + default -> "Edit Agent"; + }; + details = resourceId != null ? agent(resourceId) : String.valueOf(text(json, "agentName")); + } + case "DDSACCOUNTTYPE:CREATE", "DDSACCOUNTTYPE:UPDATE" -> { + type = "CREATE".equals(action) ? "New DDS Product" : "Edit DDS Product"; + details = text(json, "accountTypeCode") != null ? text(json, "accountTypeCode") : scheme(resourceId); + } + case "COMMISSIONSLAB:CREATE", "COMMISSIONSLAB:UPDATE" -> { + type = "CREATE".equals(action) ? "New Commission Slab" : "Edit Commission Slab"; + details = resourceId == null ? "" : "Slab #" + resourceId; + } + case "DDSBATCH:POST" -> { + final Map run = batchRun(resourceId); + type = "Post " + String.valueOf(run.getOrDefault("jobType", "Batch")).replace('_', ' ').toLowerCase(); + type = Character.toUpperCase(type.charAt(0)) + type.substring(1); + details = "Run #" + resourceId + " · " + run.getOrDefault("totalRows", 0) + " account(s) · as on " + run.getOrDefault("jobDate", ""); + amount = (BigDecimal) run.get("totalAmount"); + } + case "DDSCOLLECTIONENTRY:SETTLE" -> { + type = "Apply Receipt"; + details = agent(longOf(json, "agentId")) + (text(json, "entryDate") == null ? "" : " · " + text(json, "entryDate")); + // Waiting: what the agent has outstanding. Approved: replaced by the voucher's own figure below. + amount = status == STATUS_AWAITING ? collectionTotal(longOf(json, "agentId"), status) : null; + } + default -> details = ""; + } + row.put("type", type); + row.put("details", details); + row.put("amount", amount); + final Long commandId = ((Number) entry.get("id")).longValue(); + final String voucherNo = switch (status) { + case STATUS_PROCESSED -> voucherFor(commandId, entity, action, resourceId, json, (String) entry.get("madeOn")); + // Numbered when it was sent for approval; a rejected one keeps its number, marked cancelled. + case STATUS_AWAITING -> heldVoucher(commandId); + case STATUS_REJECTED -> first("select voucher_no from m_voucher where reference_key = ? order by id desc limit 1", + REF_KEY_PREFIX + commandId + ":REJECTED"); + default -> null; + }; + row.put("voucherNo", voucherNo); + row.put("voucherCancelled", status == STATUS_REJECTED && voucherNo != null); + if (voucherNo != null && "DDSCOLLECTIONENTRY".equals(entity)) { + // What was actually applied, as the voucher records it. + final List applied = this.jdbcTemplate.queryForList("select amount from m_voucher where voucher_no = ?", // NOSONAR + BigDecimal.class, voucherNo); + row.put("amount", applied.isEmpty() ? row.get("amount") : applied.get(0)); + } + return row; + } + + private String voucherFor(final Long commandId, final String entity, final String action, final Long resourceId, final JsonObject json, + final String madeOn) { + final List linked = this.jdbcTemplate.queryForList( // NOSONAR + "select voucher_no from m_voucher where reference_key = ? order by id desc limit 1", String.class, REF_KEY_PREFIX + commandId); + if (!linked.isEmpty()) { + return linked.get(0); + } + // Approved without passing through this screen - a user whose work needs no checker, say. + if ("DDSBATCH".equals(entity) && resourceId != null) { + return first("select voucher_no from m_voucher where reference_type = 'DDS_BATCH_RUN' and reference_id = ? order by id desc limit 1", + resourceId); + } + if ("PARTYTRANSACTION".equals(entity) && resourceId != null) { + return first("select voucher_no from m_dds_party_transaction where id = ?", resourceId); + } + if ("DDSCOLLECTIONENTRY".equals(entity) && longOf(json, "agentId") != null && madeOn != null) { + final List found = this.jdbcTemplate.queryForList( // NOSONAR + "select voucher_no from m_voucher where voucher_type = 'DDS_APPLY_RECEIPT' and reference_id = ? " + + "and created_at >= cast(? as timestamp) - interval '1 minute' order by id limit 1", + String.class, longOf(json, "agentId"), madeOn); + return found.isEmpty() ? null : found.get(0); + } + if ("PARTY".equals(entity) && resourceId != null && ("PAYOUT".equals(action) || action.startsWith("PREMATURECLOSE"))) { + return first("select voucher_no from m_dds_party_transaction where party_id = ? and voucher_no is not null " + + "and transaction_type in ('MATURITY_PAYOUT', 'PREMATURE_PAYOUT') order by id desc limit 1", resourceId); + } + return null; + } + + private String destination(final JsonObject json) { + final String toAccount = text(json, "toSavingsAccountNo"); + if (toAccount != null && !toAccount.isBlank()) { + return "Savings " + toAccount; + } + final Long glId = longOf(json, "sundryGlAccountId"); + if (glId != null) { + final String gl = first("select gl_code || ' ' || name from acc_gl_account where id = ?", glId); + return "Sundry " + (gl == null ? "#" + glId : gl); + } + return "Sundry"; + } + + private String party(final String partyCode) { + if (partyCode == null) { + return ""; + } + final String name = first("select party_name from m_dds_party where party_code = ?", partyCode); + return name == null ? partyCode : partyCode + " " + name; + } + + private String partyById(final Long partyId) { + if (partyId == null) { + return ""; + } + final String found = first("select party_code || ' ' || party_name from m_dds_party where id = ?", partyId); + return found == null ? "Party #" + partyId : found; + } + + private String agent(final Long agentId) { + if (agentId == null) { + return ""; + } + final String found = first("select agent_code || ' ' || coalesce(agent_name, '') from m_dds_agent where id = ?", agentId); + return found == null ? "Agent #" + agentId : found.trim(); + } + + private String scheme(final Long accountTypeId) { + return accountTypeId == null ? "" : first("select account_type_code from m_dds_account_type where id = ?", accountTypeId); + } + + private BigDecimal balanceOf(final Long partyId) { + final List found = this.jdbcTemplate.queryForList("select current_balance from m_dds_party where id = ?", // NOSONAR + BigDecimal.class, partyId); + return found.isEmpty() ? null : found.get(0); + } + + private BigDecimal paidOut(final Long partyId, final String transactionType) { + final List found = this.jdbcTemplate.queryForList( // NOSONAR + "select amount from m_dds_party_transaction where party_id = ? and transaction_type = ? order by id desc limit 1", + BigDecimal.class, partyId, transactionType); + return found.isEmpty() ? null : found.get(0); + } + + private BigDecimal collectionTotal(final Long agentId, final int status) { + if (agentId == null) { + return null; + } + final String state = status == STATUS_AWAITING ? "PENDING" : "SETTLED"; + final List found = this.jdbcTemplate.queryForList( // NOSONAR + "select sum(amount) from m_dds_collection_entry where agent_id = ? and status = ?", BigDecimal.class, agentId, state); + return found.isEmpty() ? null : found.get(0); + } + + private Map batchRun(final Long batchRunId) { + if (batchRunId == null) { + return Map.of(); + } + return this.jdbcTemplate.queryForList("select job_type as \"jobType\", total_rows as \"totalRows\", total_amount as \"totalAmount\", " // NOSONAR + + "to_char(job_date, 'DD/MM/YYYY') as \"jobDate\" from m_dds_batch_run where id = ?", batchRunId).stream().findFirst() + .orElse(Map.of()); + } + + private String first(final String sql, final Object param) { + final List found = this.jdbcTemplate.queryForList(sql, String.class, param); // NOSONAR + return found.isEmpty() ? null : found.get(0); + } + + private static JsonObject jsonOf(final Object text) { + if (!(text instanceof String json) || json.isBlank()) { + return new JsonObject(); + } + try { + final JsonElement element = JsonParser.parseString(json); + return element.isJsonObject() ? element.getAsJsonObject() : new JsonObject(); + } catch (RuntimeException e) { + return new JsonObject(); + } + } + + private static String text(final JsonObject json, final String name) { + return json.has(name) && !json.get(name).isJsonNull() ? json.get(name).getAsString() : null; + } + + private static BigDecimal number(final JsonObject json, final String name) { + try { + return json.has(name) && !json.get(name).isJsonNull() ? json.get(name).getAsBigDecimal() : null; + } catch (RuntimeException e) { + return null; + } + } + + private static Long longOf(final JsonObject json, final String name) { + try { + return json.has(name) && !json.get(name).isJsonNull() ? json.get(name).getAsLong() : null; + } catch (RuntimeException e) { + return null; + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/api/BatchRunsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/api/BatchRunsApiResource.java new file mode 100644 index 000000000..bbdccaec6 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/api/BatchRunsApiResource.java @@ -0,0 +1,110 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.batch.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.core.MediaType; +import java.util.Collection; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService; +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.batch.data.BatchRunData; +import org.apache.fineract.portfolio.dds.batch.service.BatchRunReadPlatformService; +import org.springframework.stereotype.Component; + +@Path("/v1/dds/batchruns") +@Component +@Tag(name = "DDS Batch Runs", description = "The Interest/Commission calculate-then-post batch: Generate previews a run's rows without changing any balance; Post applies exactly those previewed rows. A run can only be posted once.") +@RequiredArgsConstructor +public class BatchRunsApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DDSBATCH"; + + private final PlatformSecurityContext context; + private final BatchRunReadPlatformService readPlatformService; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final DdsVoucherService ddsVoucherService; + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve DDS Batch Runs", description = "Returns the list of DDS batch runs, most recent first.") + public Collection retrieveAll() { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return readPlatformService.retrieveAll(); + } + + @GET + @Path("{batchRunId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve a DDS Batch Run", description = "Returns the details of a batch run, including its previewed/posted rows.") + public BatchRunData retrieveOne(@PathParam("batchRunId") @Parameter(description = "batchRunId") final Long batchRunId) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return readPlatformService.retrieveOne(batchRunId); + } + + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Generate a DDS Batch Run", description = "Mandatory fields: jobType, branchScope (All or Selected), " + + "jobDate, agentFrom, agentTo\n" + "\n" + + "jobType is one of four Calculate/Post pairs. A calculation job works out what has been earned since the last one " + + "and parks it (interest in the Party's accrued-interest figure, commission in the Agent's owed figure); the " + + "matching posting job moves that parked figure into the customer's balance or out to the agent: " + + "INTEREST_CALCULATION / INTEREST_POSTING, OD_INTEREST_CALCULATION / OD_INTEREST_POSTING, " + + "COMMISSION_CALCULATION / COMMISSION_POSTING\n" + "\n" + "Optional fields: partyFrom, partyTo (interest jobs only)") + public CommandProcessingResult generate(@Parameter(hidden = true) final String apiRequestBodyAsJson) { + final CommandWrapper commandRequest = new CommandWrapperBuilder() // + .generateDdsBatchRun() // + .withJson(apiRequestBodyAsJson) // + .build(); + return commandsSourceWritePlatformService.logCommandSource(commandRequest); + } + + @POST + @Path("{batchRunId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Post a DDS Batch Run", description = "Applies exactly the rows a prior Generate call previewed: credits " + + "each row's amount to the Party (interest jobs) or to the Agent's running commission total (commission jobs). " + + "Fails if the run has already been posted.") + public CommandProcessingResult post(@PathParam("batchRunId") @Parameter(description = "batchRunId") final Long batchRunId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + final CommandWrapper commandRequest = new CommandWrapperBuilder() // + .postDdsBatchRun(batchRunId) // + .withJson(apiRequestBodyAsJson) // + .build(); + return ddsVoucherService.submitHoldingVoucher(ddsVoucherService.voucherTypeOfRun(batchRunId), DdsVoucherService.REF_BATCH_RUN, + batchRunId, () -> ddsVoucherService.amountOfRun(batchRunId), + () -> commandsSourceWritePlatformService.logCommandSource(commandRequest)); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/domain/BatchRunRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/domain/BatchRunRepository.java new file mode 100644 index 000000000..a4611d9e3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/domain/BatchRunRepository.java @@ -0,0 +1,25 @@ +/** + * 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 org.springframework.data.jpa.repository.JpaRepository; + +public interface BatchRunRepository extends JpaRepository { + +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/handler/GenerateBatchRunCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/handler/GenerateBatchRunCommandHandler.java new file mode 100644 index 000000000..d85d314b7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/handler/GenerateBatchRunCommandHandler.java @@ -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.batch.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.batch.service.BatchRunWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "DDSBATCH", action = "GENERATE") +public class GenerateBatchRunCommandHandler implements NewCommandSourceHandler { + + private final BatchRunWritePlatformService writePlatformService; + + @Autowired + public GenerateBatchRunCommandHandler(final BatchRunWritePlatformService writePlatformService) { + this.writePlatformService = writePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.generateBatchRun(command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/handler/PostBatchRunCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/handler/PostBatchRunCommandHandler.java new file mode 100644 index 000000000..12c0450c5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/handler/PostBatchRunCommandHandler.java @@ -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.batch.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.batch.service.BatchRunWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "DDSBATCH", action = "POST") +public class PostBatchRunCommandHandler implements NewCommandSourceHandler { + + private final BatchRunWritePlatformService writePlatformService; + + @Autowired + public PostBatchRunCommandHandler(final BatchRunWritePlatformService writePlatformService) { + this.writePlatformService = writePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.postBatchRun(command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/jobs/postddsinterest/PostDdsInterestConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/jobs/postddsinterest/PostDdsInterestConfig.java new file mode 100644 index 000000000..7e27df5f9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/jobs/postddsinterest/PostDdsInterestConfig.java @@ -0,0 +1,63 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.batch.jobs.postddsinterest; + +import org.apache.fineract.infrastructure.jobs.service.JobName; +import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +/** Wires the Post DDS Interest job into the platform scheduler. */ +@Configuration +public class PostDdsInterestConfig { + + @Autowired + private JobRepository jobRepository; + + @Autowired + private PlatformTransactionManager transactionManager; + + @Autowired + private BatchRunWritePlatformService batchRunWritePlatformService; + + @Bean + public PostDdsInterestTasklet postDdsInterestTasklet() { + return new PostDdsInterestTasklet(batchRunWritePlatformService); + } + + @Bean + protected Step postDdsInterestStep() { + return new StepBuilder(JobName.POST_DDS_INTEREST.name(), jobRepository) + .tasklet(postDdsInterestTasklet(), transactionManager).build(); + } + + @Bean + public Job postDdsInterestJob() { + return new JobBuilder(JobName.POST_DDS_INTEREST.name(), jobRepository).start(postDdsInterestStep()) + .incrementer(new RunIdIncrementer()).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/jobs/postddsinterest/PostDdsInterestTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/jobs/postddsinterest/PostDdsInterestTasklet.java new file mode 100644 index 000000000..72bf1668f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/jobs/postddsinterest/PostDdsInterestTasklet.java @@ -0,0 +1,83 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.batch.jobs.postddsinterest; + +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; + +/** + * Posts DDS interest each night: the schemes whose day has come, and every account that has matured. + * + * The Account Type screen lets a scheme say "post interest automatically, monthly, on this day", and + * the first pass acts on that. On the great majority of days no scheme is due and it does nothing. + * + * The second pass is there because that first one is not enough on its own. A scheme's posting is + * monthly and can be switched off entirely, so a term ending between two runs leaves its last stretch + * uncredited - and a payout made in the meantime hands the customer less than they earned. Maturity + * is the one date that cannot wait for the next run, so accounts reaching it are brought up to date + * here, whatever their scheme says. By morning the account is whole, and the figure the clerk reads + * on the closure list is the figure that will be paid. + * + * It works off the platform's business date rather than the wall clock, so a posting always belongs to + * the day the bank is currently working on. + */ +@Slf4j +@RequiredArgsConstructor +public class PostDdsInterestTasklet implements Tasklet { + + private final BatchRunWritePlatformService batchRunWritePlatformService; + + @Override + public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + + final int schemesPosted = this.batchRunWritePlatformService.postDueAutoInterest(businessDate); + + if (schemesPosted == 0) { + log.debug("Post DDS Interest: no scheme is due on {}", businessDate); + } else { + log.info("Post DDS Interest: posted interest for {} scheme(s) as on {}", schemesPosted, businessDate); + } + + final int maturedPosted = this.batchRunWritePlatformService.postMaturityInterest(businessDate); + + if (maturedPosted == 0) { + log.debug("Post DDS Interest: no matured account is short of interest on {}", businessDate); + } else { + log.info("Post DDS Interest: brought {} matured account(s) up to their maturity date as on {}", maturedPosted, businessDate); + } + + final int promoted = this.batchRunWritePlatformService.promoteMinorsWhoHaveComeOfAge(businessDate); + + if (promoted == 0) { + log.debug("Post DDS Interest: no minor's account came of age with KYC verified on {}", businessDate); + } else { + log.info("Post DDS Interest: turned {} minor's account(s) over to their holder as on {}", promoted, businessDate); + } + + return RepeatStatus.FINISHED; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/serialization/BatchRunCommandFromApiJsonDeserializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/serialization/BatchRunCommandFromApiJsonDeserializer.java new file mode 100644 index 000000000..09d317aad --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/serialization/BatchRunCommandFromApiJsonDeserializer.java @@ -0,0 +1,118 @@ +/** + * 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.serialization; + +import com.google.gson.JsonElement; +import com.google.gson.reflect.TypeToken; +import java.lang.reflect.Type; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; +import org.apache.fineract.infrastructure.core.exception.InvalidJsonException; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public final class BatchRunCommandFromApiJsonDeserializer { + + // The four Calculate/Post pairs from the legacy Interest Part menu. A calculation job works out + // what has been earned and parks it; the matching posting job moves it where it belongs. + // + // Lists, not Sets, so isOneOfTheseStringValues resolves to the overload that lowercases both + // sides - the varargs one lowercases only the submitted value and so never matches these. + public static final List SUPPORTED_JOB_TYPES = List.of("INTEREST_CALCULATION", "INTEREST_POSTING", + "OD_INTEREST_CALCULATION", "OD_INTEREST_POSTING", "COMMISSION_CALCULATION", "COMMISSION_POSTING", + "MATURITY_AUTO_CLOSE"); + + private static final List SUPPORTED_BRANCH_SCOPES = List.of("All", "Selected"); + + private static final Set SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("jobType", "branchScope", "officeNames", + "jobDate", "agentFrom", "agentTo", "partyFrom", "partyTo", "subType", "dateFormat", "locale")); + + private final FromJsonHelper fromApiJsonHelper; + + @Autowired + public BatchRunCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) { + this.fromApiJsonHelper = fromApiJsonHelper; + } + + public void validateForGenerate(final String json) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + + final Type typeOfMap = new TypeToken>() { + + }.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("ddsbatchrun"); + + final JsonElement element = this.fromApiJsonHelper.parse(json); + + v.reset().parameter("jobType").value(this.fromApiJsonHelper.extractStringNamed("jobType", element)).notBlank() + .isOneOfTheseStringValues(SUPPORTED_JOB_TYPES); + final String branchScope = this.fromApiJsonHelper.extractStringNamed("branchScope", element); + v.reset().parameter("branchScope").value(branchScope).notBlank().isOneOfTheseStringValues(SUPPORTED_BRANCH_SCOPES); + + if ("Selected".equals(branchScope)) { + v.reset().parameter("officeNames").value(this.fromApiJsonHelper.extractJsonArrayNamed("officeNames", element)) + .jsonArrayNotEmpty(); + } + + // Not in the future. A run works out what has been earned up to its job date, so a date that + // has not arrived pays interest for days nobody has held their money yet - and worse, the run + // stamps itself as calculated through that date, so the real days are skipped when they come. + final LocalDate jobDate = this.fromApiJsonHelper.extractLocalDateNamed("jobDate", element); + v.reset().parameter("jobDate").value(jobDate).notNull().validateDateBeforeOrEqual(DateUtils.getBusinessLocalDate()); + v.reset().parameter("agentFrom").value(this.fromApiJsonHelper.extractLongNamed("agentFrom", element)).notNull() + .integerGreaterThanZero(); + v.reset().parameter("agentTo").value(this.fromApiJsonHelper.extractLongNamed("agentTo", element)).notNull() + .integerGreaterThanZero(); + + if (this.fromApiJsonHelper.parameterExists("partyFrom", element)) { + v.reset().parameter("partyFrom").value(this.fromApiJsonHelper.extractLongNamed("partyFrom", element)).ignoreIfNull() + .integerGreaterThanZero(); + } + if (this.fromApiJsonHelper.parameterExists("partyTo", element)) { + v.reset().parameter("partyTo").value(this.fromApiJsonHelper.extractLongNamed("partyTo", element)).ignoreIfNull() + .integerGreaterThanZero(); + } + + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + + private void throwExceptionIfValidationWarningsExist(final List dataValidationErrors) { + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", + dataValidationErrors); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunReadPlatformService.java new file mode 100644 index 000000000..79838a36d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunReadPlatformService.java @@ -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.batch.service; + +import java.util.Collection; +import org.apache.fineract.portfolio.dds.batch.data.BatchRunData; + +public interface BatchRunReadPlatformService { + + Collection retrieveAll(); + + BatchRunData retrieveOne(Long batchRunId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunReadPlatformServiceImpl.java new file mode 100644 index 000000000..49103413e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunReadPlatformServiceImpl.java @@ -0,0 +1,152 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.batch.service; + +import java.sql.ResultSet; +import java.sql.SQLException; +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.portfolio.dds.batch.data.BatchRunData; +import org.apache.fineract.portfolio.dds.batch.data.BatchRunRowData; +import org.apache.fineract.portfolio.dds.batch.exception.BatchRunNotFoundException; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +@RequiredArgsConstructor +public class BatchRunReadPlatformServiceImpl implements BatchRunReadPlatformService { + + private final JdbcTemplate jdbcTemplate; + + private static final BatchRunMapper BATCH_RUN_MAPPER = new BatchRunMapper(); + private static final BatchRunRowMapper ROW_MAPPER = new BatchRunRowMapper(); + + private static final class BatchRunMapper implements RowMapper { + + public String schema() { + return " b.id as id, b.job_type as jobType, b.branch_scope as branchScope, b.sub_type as subType, b.job_date as jobDate, " + + " b.agent_from as agentFrom, b.agent_to as agentTo, b.party_from as partyFrom, " + + " b.party_to as partyTo, b.status as status, b.total_rows as totalRows, " + + " b.rows_posted as rowsPosted, b.failure_reason as failureReason, b.total_amount as totalAmount, " + + " b.generated_on_date as generatedOnDate, b.posted_on_date as postedOnDate from m_dds_batch_run b"; + } + + @Override + public BatchRunData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + return BatchRunData.builder() // + .id(rs.getLong("id")) // + .jobType(rs.getString("jobType")) // + .branchScope(rs.getString("branchScope")) // + .subType(rs.getString("subType")) // + .jobDate(JdbcSupport.getLocalDate(rs, "jobDate")) // + .agentFrom(rs.getLong("agentFrom")) // + .agentTo(rs.getLong("agentTo")) // + .partyFrom(JdbcSupport.getLong(rs, "partyFrom")) // + .partyTo(JdbcSupport.getLong(rs, "partyTo")) // + .status(rs.getString("status")) // + .totalRows(rs.getInt("totalRows")) // + .rowsPosted(rs.getInt("rowsPosted")) // + .failureReason(rs.getString("failureReason")) // + .totalAmount(rs.getBigDecimal("totalAmount")) // + .generatedOnDate(JdbcSupport.getLocalDate(rs, "generatedOnDate")) // + .postedOnDate(JdbcSupport.getLocalDate(rs, "postedOnDate")) // + .rows(new ArrayList<>()) // + .build(); + } + } + + private static final class BatchRunRowMapper implements RowMapper> { + + public String schema() { + return " r.batch_run_id as batchRunId, r.id as id, r.party_id as partyId, p.party_code as partyCode, " + + " p.party_name as partyName, r.agent_id as agentId, a.agent_code as agentCode, " + + " a.agent_name as agentName, r.amount as amount, r.base_amount as baseAmount, " + + " r.rate_percent as ratePercent, r.gross_amount as grossAmount, r.tds_amount as tdsAmount, " + + " r.deduction_amount as deductionAmount, r.from_date as fromDate, r.to_date as toDate " + + " from m_dds_batch_run_row r " + " left join m_dds_party p on p.id = r.party_id " + + " join m_dds_agent a on a.id = r.agent_id"; + } + + @Override + public Map.Entry mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long batchRunId = rs.getLong("batchRunId"); + final BatchRunRowData data = BatchRunRowData.builder() // + .id(rs.getLong("id")) // + .partyId(JdbcSupport.getLong(rs, "partyId")) // + .partyCode(rs.getString("partyCode")) // + .partyName(rs.getString("partyName")) // + .agentId(rs.getLong("agentId")) // + .agentCode(rs.getString("agentCode")) // + .agentName(rs.getString("agentName")) // + .amount(rs.getBigDecimal("amount")) // + .baseAmount(rs.getBigDecimal("baseAmount")) // + .ratePercent(rs.getBigDecimal("ratePercent")) // + .grossAmount(rs.getBigDecimal("grossAmount")) // + .tdsAmount(rs.getBigDecimal("tdsAmount")) // + .deductionAmount(rs.getBigDecimal("deductionAmount")) // + .fromDate(JdbcSupport.getLocalDate(rs, "fromDate")) // + .toDate(JdbcSupport.getLocalDate(rs, "toDate")) // + .build(); + return Map.entry(batchRunId, data); + } + } + + @Override + public Collection retrieveAll() { + final String sql = "select " + BATCH_RUN_MAPPER.schema() + " order by b.id desc"; + final List batchRuns = this.jdbcTemplate.query(sql, BATCH_RUN_MAPPER); // NOSONAR + return attachRows(batchRuns); + } + + @Override + public BatchRunData retrieveOne(final Long batchRunId) { + try { + final String sql = "select " + BATCH_RUN_MAPPER.schema() + " where b.id = ?"; + final BatchRunData batchRun = this.jdbcTemplate.queryForObject(sql, BATCH_RUN_MAPPER, batchRunId); // NOSONAR + return attachRows(List.of(batchRun)).iterator().next(); + } catch (final EmptyResultDataAccessException e) { + throw new BatchRunNotFoundException(batchRunId); + } + } + + private Collection attachRows(final List batchRuns) { + if (batchRuns.isEmpty()) { + return batchRuns; + } + + final String sql = "select " + ROW_MAPPER.schema() + " order by r.id"; + final List> rows = this.jdbcTemplate.query(sql, ROW_MAPPER); // NOSONAR + + final Map> rowsByBatchRunId = new LinkedHashMap<>(); + for (final Map.Entry row : rows) { + rowsByBatchRunId.computeIfAbsent(row.getKey(), k -> new ArrayList<>()).add(row.getValue()); + } + + final List result = new ArrayList<>(batchRuns.size()); + for (final BatchRunData b : batchRuns) { + result.add(b.toBuilder().rows(rowsByBatchRunId.getOrDefault(b.getId(), new ArrayList<>())).build()); + } + return result; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformService.java new file mode 100644 index 000000000..7163b28e6 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformService.java @@ -0,0 +1,88 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.batch.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.dds.party.domain.Party; + +public interface BatchRunWritePlatformService { + + CommandProcessingResult generateBatchRun(JsonCommand command); + + CommandProcessingResult postBatchRun(Long batchRunId, JsonCommand command); + + /** + * Posts interest for every scheme whose Account Type says to do it automatically and whose posting + * day falls on the given date. Driven by the Post DDS Interest scheduled job. + * + * @return how many schemes were posted for - zero on the great majority of days, when nothing is + * due. + */ + int postDueAutoInterest(LocalDate businessDate); + + /** + * Brings every matured account's interest up to its own maturity date. + * + * Run nightly beside the scheme postings. Interest otherwise arrives only when a posting run + * happens - monthly, per scheme, and switchable off - so a term ending between two runs leaves + * its last stretch uncredited, and a payout in the meantime pays the customer short. + * + * @return how many accounts were credited; zero on most nights. + */ + int postMaturityInterest(LocalDate businessDate); + + /** + * Works out one account's interest up to a date and credits it, returning what was paid. + * + * Called by the nightly pass above, and again by the maturity payout as a second line of defence + * in case that pass never ran. Returns zero, having written nothing, when interest is already + * current. + */ + BigDecimal postInterestThrough(Party party, LocalDate through); + + /** + * The interest an account is owed up to a day and has not yet had credited - calculated and left + * unposted, or not calculated at all. Nothing is changed; this only says what a calculation and + * posting run would still credit. + */ + BigDecimal interestNotYetPosted(Party party, LocalDate through); + + /** + * Turns over the minors' accounts whose holders have come of age and had their own KYC verified. + * + * Run nightly. The birthday alone does not do it: an eighteen-year-old the bank has not yet + * identified is somebody it cannot pay, so the turnover waits on their KYC - and until then the + * payout refuses while collections carry on as before. + * + * @return how many accounts were turned over; zero on almost every night. + */ + int promoteMinorsWhoHaveComeOfAge(LocalDate businessDate); + + /** + * Credits every row on a run and marks it posted. Called from the background thread that + * {@code postBatchRun} hands the work to, not from a request. + */ + void applyAndMarkPosted(Long batchRunId); + + /** Records on the run why it stopped, when it did, so a screen can say so rather than only a log. */ + void markPostingFailed(Long batchRunId, String reason); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformServiceJpaRepositoryImpl.java new file mode 100644 index 000000000..99adee1b3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformServiceJpaRepositoryImpl.java @@ -0,0 +1,1057 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.batch.service; + +import com.google.gson.JsonArray; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.organisation.agent.domain.Agent; +import org.apache.fineract.organisation.agent.domain.AgentRepository; +import org.apache.fineract.organisation.agent.exception.AgentCommissionAccountUnusableException; +import org.apache.fineract.organisation.agent.exception.AgentNotFoundException; +import org.apache.fineract.organisation.commissionslab.domain.CommissionSlab; +import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository; +import org.apache.fineract.portfolio.dds.accounting.DdsLedger; +import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService; +import org.apache.fineract.portfolio.dds.calculation.DdsCommissionCalculation; +import org.apache.fineract.portfolio.dds.calculation.DdsInterestCalculation; +import org.apache.fineract.portfolio.dds.batch.domain.BatchRun; +import org.apache.fineract.portfolio.dds.batch.domain.BatchRunRepository; +import org.apache.fineract.portfolio.dds.batch.domain.BatchRunRow; +import org.apache.fineract.portfolio.dds.batch.exception.BatchRunAlreadyPostedException; +import org.apache.fineract.portfolio.dds.batch.exception.BatchRunNotFoundException; +import org.apache.fineract.portfolio.dds.batch.exception.NoCommissionSlabException; +import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountType; +import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountTypeRepository; +import org.apache.fineract.portfolio.dds.batch.serialization.BatchRunCommandFromApiJsonDeserializer; +import org.apache.fineract.portfolio.dds.party.domain.Party; +import org.apache.fineract.portfolio.dds.party.domain.PartyRepository; +import org.apache.fineract.portfolio.dds.party.exception.PartyNotFoundException; +import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService; +import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransaction; +import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** + * The DDS Interest Part: the four Calculate/Post job pairs from the legacy menu. + * + * A calculation job works out what has been earned since the last one and parks it - interest + * in the Party's "Int.Calc" figure, commission in the Agent's "Comm" figure. Neither is spendable yet. + * A posting job then moves that parked figure where it belongs: into the customer's balance, + * or out to the agent. This is why the legacy menu has eight entries rather than four. + * + * Every job is itself a two-step Generate/Post: Generate saves the rows it worked out and changes + * nothing else, Post applies exactly those rows. A figure that looks wrong is thrown away by simply + * not posting it. + * + * Nothing here can be applied twice. A calculation stamps how far it has calculated through, so + * re-running it for the same period finds no days left; a posting takes its amount off the parked + * figure, so re-posting finds nothing left to move. + */ +@RequiredArgsConstructor +public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWritePlatformService { + + private static final String INTEREST_CALCULATION = "INTEREST_CALCULATION"; + private static final String INTEREST_POSTING = "INTEREST_POSTING"; + private static final String OD_INTEREST_CALCULATION = "OD_INTEREST_CALCULATION"; + private static final String OD_INTEREST_POSTING = "OD_INTEREST_POSTING"; + private static final String COMMISSION_CALCULATION = "COMMISSION_CALCULATION"; + private static final String COMMISSION_POSTING = "COMMISSION_POSTING"; + private static final String MATURITY_AUTO_CLOSE = "MATURITY_AUTO_CLOSE"; + + private static final String ACCOUNT_SLAB = "ACCOUNT"; + + private static final String DIRECTION_CREDIT = "CREDIT"; + private static final String DIRECTION_DEBIT = "DEBIT"; + + private final BatchRunCommandFromApiJsonDeserializer fromApiJsonDeserializer; + private final BatchRunRepository batchRunRepository; + private final PartyRepository partyRepository; + private final AgentRepository agentRepository; + private final PartyTransactionRepository partyTransactionRepository; + private final CommissionSlabRepository commissionSlabRepository; + private final DdsAccountTypeRepository ddsAccountTypeRepository; + + /** Puts a commission payment onto the bank's own books as well as DDS's - see DdsLedger. */ + private final DdsLedger ddsLedger; + /** Runs the crediting away from the request that asked for it - see postBatchRun. */ + private final DdsBatchPostingRunner postingRunner; + private final JdbcTemplate jdbcTemplate; + + /** + * Settles a matured account, for a Maturity Auto Close run. + * + * Held as a provider rather than the service itself because the two point at each other - paying + * a maturity brings its interest up to date through this class, and this class hands the settling + * back to that one. Asked for at the moment it is used, the pair wire up; asked for at + * construction, neither can be built. Reused rather than reimplemented here so an automatic + * settlement and a clerk's go through the same code and cannot drift apart. + */ + private final ObjectProvider partyWritePlatformService; + + /** An agent's collections no commission run has counted yet, up to a date, and the earliest of them. */ + private static final String UNCOMMISSIONED_COLLECTIONS_SQL = "select coalesce(sum(amount), 0) as volume, " + + "min(transaction_date) as from_date from m_dds_party_transaction where agent_id = ? and transaction_type = 'PARTY_TRAN' " + + "and direction = 'CREDIT' and commission_batch_run_id is null and transaction_date <= ?"; + + /** Marks those collections as counted by a run. */ + private static final String MARK_COLLECTIONS_COMMISSIONED_SQL = "update m_dds_party_transaction set commission_batch_run_id = ? " + + "where agent_id = ? and transaction_type = 'PARTY_TRAN' and direction = 'CREDIT' and commission_batch_run_id is null " + + "and transaction_date <= ?"; + + /** + * Everything needed to say whether an agent's commission can be paid into an account, and if not, + * why. Read in one go so each reason can be named rather than the deposit simply being attempted. + */ + private static final String COMMISSION_ACCOUNT_SQL = "select a.id, a.status_enum, a.product_id, p.deposit_type_enum " + + "from m_savings_account a join m_savings_product p on p.id = a.product_id where a.account_no = ?"; + + /** The product DDS opens customers' scheme accounts under - see 0492. */ + private static final String DDS_DEPOSIT_PRODUCT_SQL = "select value from c_configuration " + + "where name = 'dds-deposit-product' and enabled = true"; + + private static final int SAVINGS_DEPOSIT_TYPE = 100; + private static final int FIXED_DEPOSIT_TYPE = 200; + private static final int ACTIVE_SAVINGS_STATUS = 300; + private static final int DORMANT_SAVINGS_STATUS = 303; + + @Transactional + @Override + public CommandProcessingResult generateBatchRun(final JsonCommand command) { + + this.fromApiJsonDeserializer.validateForGenerate(command.json()); + + final String jobType = command.stringValueOfParameterNamed("jobType"); + final String branchScope = command.stringValueOfParameterNamed("branchScope"); + final LocalDate jobDate = command.localDateValueOfParameterNamed("jobDate"); + final Long agentFrom = command.longValueOfParameterNamed("agentFrom"); + final Long agentTo = command.longValueOfParameterNamed("agentTo"); + final Long partyFrom = command.parameterExists("partyFrom") ? command.longValueOfParameterNamed("partyFrom") : 1L; + final Long partyTo = command.parameterExists("partyTo") ? command.longValueOfParameterNamed("partyTo") : Long.MAX_VALUE; + + final Set officeNames = "Selected".equals(branchScope) ? extractOfficeNames(command) : null; + + // Absent means every scheme, which is what a run started by hand from the screen does. The + // scheduled job names one, because a posting day belongs to an account type rather than the bank. + final String subType = command.parameterExists("subType") ? command.stringValueOfParameterNamed("subType") : null; + + final BatchRun batchRun = new BatchRun(jobType, branchScope, jobDate, agentFrom, agentTo, partyFrom, partyTo, LocalDate.now(), + subType); + + switch (jobType) { + case INTEREST_CALCULATION -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, false, + officeNames, subType); + case OD_INTEREST_CALCULATION -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, true, + officeNames, subType); + case INTEREST_POSTING -> generateInterestPostingRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, false, + officeNames, subType); + case OD_INTEREST_POSTING -> generateInterestPostingRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, true, + officeNames, subType); + case COMMISSION_CALCULATION -> generateCommissionRows(batchRun, agentFrom, agentTo, jobDate, officeNames); + case COMMISSION_POSTING -> generateCommissionPostingRows(batchRun, agentFrom, agentTo, jobDate, officeNames); + case MATURITY_AUTO_CLOSE -> generateMaturityAutoCloseRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, + officeNames, subType); + default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType); + } + + this.batchRunRepository.saveAndFlush(batchRun); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(batchRun.getId()) // + .build(); + } + + /** + * Parses the "Branch" checklist selection - null/empty means no filter ("All"). Matched against + * Agent.homeBranch by name, since that field is plain text (not a real Office FK) mirroring the + * same free-text branch concept the legacy screen itself used. + */ + private Set extractOfficeNames(final JsonCommand command) { + if (!command.parameterExists("officeNames")) { + return null; + } + final JsonArray officeNamesArray = command.arrayOfParameterNamed("officeNames"); + final Set officeNames = new HashSet<>(); + for (int i = 0; i < officeNamesArray.size(); i++) { + officeNames.add(officeNamesArray.get(i).getAsString().trim().toUpperCase()); + } + return officeNames; + } + + private boolean matchesSelectedBranch(final String homeBranch, final Set officeNames) { + if (officeNames == null || officeNames.isEmpty()) { + return true; + } + return homeBranch != null && officeNames.contains(homeBranch.trim().toUpperCase()); + } + + // --------------------------------------------------------------------------------------------- + // Interest calculation + // --------------------------------------------------------------------------------------------- + + /** + * Works out each account's interest since it was last calculated, up to the job date. + * + * A daily-deposit balance is built up a little at a time, so the closing balance says nothing + * about how long the money was actually held: an instalment paid on the last day of the month has + * earned a day, not a month. Each account's interest is therefore worked out from its daily + * product - its balance summed over every day it stood at that balance - which is the same basis + * the maturity figure quoted at account opening uses, so the two agree. + */ + private void generateInterestRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo, final Long partyFrom, + final Long partyTo, final LocalDate jobDate, final boolean odInterest, final Set officeNames, + final String subType) { + + final List parties = odInterest + ? this.partyRepository.findForOdInterestBatch(agentFrom, agentTo, partyFrom, partyTo, subType) + : this.partyRepository.findForInterestBatch(agentFrom, agentTo, partyFrom, partyTo, subType); + + final List eligible = new ArrayList<>(parties.size()); + for (final Party party : parties) { + if (matchesSelectedBranch(party.getAgent().getHomeBranch(), officeNames)) { + eligible.add(party); + } + } + if (eligible.isEmpty()) { + return; + } + + final Map> movements = movementsByParty(eligible, jobDate); + + for (final Party party : eligible) { + final BigDecimal rate = party.getInterestRate(); + if (rate == null || rate.compareTo(BigDecimal.ZERO) <= 0) { + continue; + } + + // Where this run picks up: the last run's through-date, or the day the account opened if + // it has never been calculated. + final LocalDate accrueFrom = party.interestCalculatedThrough(odInterest) != null + ? party.interestCalculatedThrough(odInterest) + : party.getOpeningDate(); + if (!accrueFrom.isBefore(jobDate)) { + continue; + } + + final BigDecimal dailyProduct = DdsInterestCalculation.dailyProduct(party.getOpeningBalance(), + movementsOf(movements.getOrDefault(party.getId(), List.of())), accrueFrom, jobDate); + if (dailyProduct.compareTo(BigDecimal.ZERO) <= 0) { + continue; + } + + final BigDecimal interest = DdsInterestCalculation.interestOn(dailyProduct, rate); + if (interest.compareTo(BigDecimal.ZERO) <= 0) { + continue; + } + + batchRun.addRow( + BatchRunRow.forInterest(party.getId(), party.getAgent().getId(), interest, dailyProduct, rate, accrueFrom, jobDate)); + } + } + + private Map> movementsByParty(final List parties, final LocalDate upTo) { + final List partyIds = new ArrayList<>(parties.size()); + for (final Party party : parties) { + partyIds.add(party.getId()); + } + + // One query for the whole run rather than one per account, then grouped here. The repository + // orders by party and date, so each list is already in the order the replay below needs. + final List transactions = this.partyTransactionRepository.findForPartiesUpTo(partyIds, upTo); + + final Map> byParty = new HashMap<>(); + for (final PartyTransaction transaction : transactions) { + byParty.computeIfAbsent(transaction.getParty().getId(), k -> new ArrayList<>()).add(transaction); + } + return byParty; + } + + /** + * Turns this account's transactions into the plain date/amount pairs the sum needs, with a payout + * carried as a negative. The calculation itself lives in DdsInterestCalculation, apart from the + * job that runs it, so it can be checked on its own. + */ + private List movementsOf(final List transactions) { + final List movements = new ArrayList<>(transactions.size()); + for (final PartyTransaction transaction : transactions) { + final BigDecimal signed = DIRECTION_DEBIT.equals(transaction.getDirection()) ? transaction.getAmount().negate() + : transaction.getAmount(); + movements.add(new DdsInterestCalculation.Movement(transaction.getTransactionDate(), signed)); + } + return movements; + } + + // --------------------------------------------------------------------------------------------- + // Interest posting + // --------------------------------------------------------------------------------------------- + + /** Lists the interest each account is holding, ready to be credited to its balance. */ + private void generateInterestPostingRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo, final Long partyFrom, + final Long partyTo, final LocalDate jobDate, final boolean odInterest, final Set officeNames, + final String subType) { + + final List parties = odInterest + ? this.partyRepository.findWithAccruedOdInterest(agentFrom, agentTo, partyFrom, partyTo, subType) + : this.partyRepository.findWithAccruedInterest(agentFrom, agentTo, partyFrom, partyTo, subType); + + for (final Party party : parties) { + if (!matchesSelectedBranch(party.getAgent().getHomeBranch(), officeNames)) { + continue; + } + batchRun.addRow( + BatchRunRow.forPosting(party.getId(), party.getAgent().getId(), party.accruedInterest(odInterest), jobDate)); + } + } + + // --------------------------------------------------------------------------------------------- + // Maturity auto close + // --------------------------------------------------------------------------------------------- + + /** + * Lists matured accounts that can be settled and shut without anybody choosing anything today. + * + * Two things have to agree before an account is listed. The scheme it is on must have maturity + * auto-closing switched on - that is the bank's decision, made once per scheme. And the account + * itself must have named where its proceeds go - that is the customer's, made when they opened + * it. Neither stands in for the other: a scheme with the switch on cannot send a customer's money + * somewhere they never nominated, and a nominated account means nothing on a scheme the bank has + * not turned this on for. + * + * Everything else about the account - that it has matured, still holds money, is not under lien, + * has its KYC in order - is decided by the settling itself, which applies the same rules to a + * clerk's payout. Repeating them here would be two lists of rules to keep in step. + */ + private void generateMaturityAutoCloseRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo, final Long partyFrom, + final Long partyTo, final LocalDate jobDate, final Set officeNames, final String subType) { + + final Set autoClosingSchemes = schemesWithMaturityAutoClose(); + if (autoClosingSchemes.isEmpty()) { + return; + } + + final List due = this.partyRepository.findDueForMaturityAutoClose(agentFrom, agentTo, partyFrom, partyTo, subType, jobDate); + + for (final Party party : due) { + if (!matchesSelectedBranch(party.getAgent().getHomeBranch(), officeNames)) { + continue; + } + if (!autoClosingSchemes.contains(normalisedSubType(party.getSubType()))) { + continue; + } + // The balance as it stands. Interest still to be brought up to the maturity date is added + // when the row is applied, so what is listed is the floor of what the customer receives, + // never more than it. + batchRun.addRow(BatchRunRow.forPosting(party.getId(), party.getAgent().getId(), party.getCurrentBalance(), jobDate)); + } + } + + /** The account-type codes the bank has switched maturity auto-closing on for. */ + private Set schemesWithMaturityAutoClose() { + final Set codes = new HashSet<>(); + for (final DdsAccountType accountType : this.ddsAccountTypeRepository.findAll()) { + if (accountType.isMaturityAutoClose() && accountType.isActive()) { + codes.add(normalisedSubType(accountType.getAccountTypeCode())); + } + } + return codes; + } + + /** + * An account names its scheme by the code as it was typed, and the two spellings have to meet. + * Compared without case or surrounding space for the same reason the payment modes are. + */ + private String normalisedSubType(final String subType) { + return subType == null ? "" : subType.trim().toUpperCase(Locale.ROOT); + } + + // --------------------------------------------------------------------------------------------- + // Commission calculation + // --------------------------------------------------------------------------------------------- + + /** + * Works out each agent's commission on what they collected since their last commission run. + * + * The chart gives a percentage on the collection volume; TDS and the agent's standing deductions + * come off that, leaving what the agent is actually owed. Both the gross figure and each + * deduction are recorded on the row, because that breakdown is what the Agent Commission + * Statement prints and what an agent querying their payout needs to see. + */ + private void generateCommissionRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo, final LocalDate jobDate, + final Set officeNames) { + + final List agents = this.agentRepository.findBetweenIds(agentFrom, agentTo); + + for (final Agent agent : agents) { + if (!matchesSelectedBranch(agent.getHomeBranch(), officeNames)) { + continue; + } + // An agent the bank has switched commission off for, or one no longer collecting, earns + // nothing new - but what they have already earned stays owed to them and is still payable. + if (!agent.isCommissionCalculation() || !agent.isActive()) { + continue; + } + + // Every collection no run has counted yet, whatever day it was collected on. A collection is + // credited only when the agent's cash receipt is applied - often a day or more later - so a + // window of collection dates starting after the last run would miss the ones applied late. + final Map pending = this.jdbcTemplate.queryForMap(UNCOMMISSIONED_COLLECTIONS_SQL, agent.getId(), jobDate); // NOSONAR + final BigDecimal collectionVolume = (BigDecimal) pending.get("volume"); + if (collectionVolume == null || collectionVolume.compareTo(BigDecimal.ZERO) <= 0) { + continue; + } + final LocalDate fromDate = ((java.sql.Date) pending.get("from_date")).toLocalDate(); + + // A volume that falls in a gap in the chart is a fault in the chart, not a reason to pay + // this agent nothing: an agent who worked all month would read exactly like one who + // collected nothing, and nobody would notice until they complained. + final List matchingSlabs = this.commissionSlabRepository.findMatchingSlabs(ACCOUNT_SLAB, collectionVolume); + if (matchingSlabs.isEmpty()) { + throw new NoCommissionSlabException(agent.getAgentCode(), collectionVolume); + } + + final DdsCommissionCalculation commission = DdsCommissionCalculation.of(collectionVolume, + matchingSlabs.get(0).getCommissionPercent(), agent.isTdsApplicable(), agent.getTdsPercent(), + agent.totalDeductions()); + if (!commission.isPayable()) { + // The deductions have swallowed this period's commission. Nothing is owed, and the + // agent is not put into debt over it - the deductions are standing figures the bank + // adjusts, not a balance this job is entitled to carry forward. + continue; + } + + batchRun.addRow(BatchRunRow.forCommission(agent.getId(), commission.net(), commission.collectionVolume(), + commission.ratePercent(), commission.gross(), commission.tds(), commission.deductions(), fromDate, jobDate)); + } + } + + // --------------------------------------------------------------------------------------------- + // Commission posting + // --------------------------------------------------------------------------------------------- + + /** Lists what each agent is owed, ready to be paid out. */ + private void generateCommissionPostingRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo, + final LocalDate jobDate, final Set officeNames) { + + final List agents = this.agentRepository.findWithCommissionOwed(agentFrom, agentTo); + + for (final Agent agent : agents) { + if (!matchesSelectedBranch(agent.getHomeBranch(), officeNames)) { + continue; + } + batchRun.addRow(BatchRunRow.forPosting(null, agent.getId(), agent.getCommission(), jobDate)); + } + } + + // --------------------------------------------------------------------------------------------- + // Post + // --------------------------------------------------------------------------------------------- + + @Transactional + @Override + public CommandProcessingResult postBatchRun(final Long batchRunId, final JsonCommand command) { + + final BatchRun batchRun = this.batchRunRepository.findById(batchRunId).orElseThrow(() -> new BatchRunNotFoundException(batchRunId)); + if (batchRun.isPosted() || batchRun.isPosting()) { + throw new BatchRunAlreadyPostedException(batchRunId); + } + + // Only the crediting runs is worth taking off the request. A calculation run writes a figure + // against each account and finishes in under a second on four hundred of them; sending it away + // would only mean the posting run that follows could be generated before it had finished, and + // find nothing accrued to pay. + if (!isCrediting(batchRun.getJobType())) { + applyRows(batchRun); + batchRun.markPosted(LocalDate.now()); + this.batchRunRepository.saveAndFlush(batchRun); + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(batchRunId).build(); + } + + // Marked under way and committed before the work starts, so a second press finds it already + // running rather than crediting every account twice. + batchRun.markPosting(); + this.batchRunRepository.saveAndFlush(batchRun); + + // The crediting itself runs away from this request. Each account costs what a savings deposit + // costs, and a branch of any size would leave the clerk watching a still screen with no way to + // tell a slow run from a hung one. The context a background thread does not inherit is handed + // over with it: the tenant, or there is no database; the business date, or the postings are + // dated by the wall clock; the signed-in user, or every row fails its audit column. + // + // Only once this request has committed. A run sent for a checker's approval is rolled back + // after this method returns, and a background thread started here would already be crediting + // accounts by then - the approval bypassed. After commit it starts only for a request that + // stands: the checker's approval, or a user who needs none. + final var tenant = ThreadLocalContextUtil.getTenant(); + final var businessDates = ThreadLocalContextUtil.getBusinessDates(); + final var dataSourceContext = ThreadLocalContextUtil.getDataSourceContext(); + final var securityContext = SecurityContextHolder.getContext(); + final Runnable start = () -> this.postingRunner.post(batchRunId, tenant, businessDates, dataSourceContext, securityContext); + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + + @Override + public void afterCommit() { + start.run(); + } + }); + } else { + start.run(); + } + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(batchRunId) // + .build(); + } + + /** The runs that move money into accounts, and so carry the cost of a savings deposit on each one. */ + private boolean isCrediting(final String jobType) { + return INTEREST_POSTING.equals(jobType) || OD_INTEREST_POSTING.equals(jobType) || COMMISSION_POSTING.equals(jobType); + } + + /** + * Credits every row on the run and marks it posted. Called from the background thread. + * + * One transaction for the whole run: either every account on it is credited or none is. A part- + * credited run would leave the branch to work out by hand which customers had been paid and which + * had not, and that is a worse position to be in than simply running it again. + */ + @Transactional + @Override + public void applyAndMarkPosted(final Long batchRunId) { + final BatchRun batchRun = this.batchRunRepository.findById(batchRunId).orElseThrow(() -> new BatchRunNotFoundException(batchRunId)); + applyRows(batchRun); + batchRun.markPosted(LocalDate.now()); + this.batchRunRepository.saveAndFlush(batchRun); + } + + /** Writes onto the run why it stopped, in a transaction of its own - the failed one has rolled back. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + @Override + public void markPostingFailed(final Long batchRunId, final String reason) { + this.batchRunRepository.findById(batchRunId).ifPresent(run -> { + run.markFailed(reason); + this.batchRunRepository.saveAndFlush(run); + }); + } + + /** + * Applies exactly the rows a Generate call worked out. Shared by the screen's Post button and by + * the scheduled job, so an automatic posting goes through the same code as a manual one rather + * than a second implementation that could drift from it. + */ + private void applyRows(final BatchRun batchRun) { + // One DDS voucher for the whole run - the expense on one side, every account credited on the + // other. A calculation run moves no money and raises none. + this.ddsLedger.withVoucher(voucherTypeFor(batchRun.getJobType()), DdsVoucherService.REF_BATCH_RUN, batchRun.getId(), () -> { + applyEachRow(batchRun); + return null; + }); + } + + private static String voucherTypeFor(final String jobType) { + if (COMMISSION_POSTING.equals(jobType)) { + return DdsVoucherService.TYPE_COMMISSION; + } + return MATURITY_AUTO_CLOSE.equals(jobType) ? DdsVoucherService.TYPE_MATURITY_PAYOUT : DdsVoucherService.TYPE_INTEREST; + } + + private void applyEachRow(final BatchRun batchRun) { + final String jobType = batchRun.getJobType(); + final LocalDate jobDate = batchRun.getJobDate(); + + int done = 0; + for (final BatchRunRow row : batchRun.getRows()) { + switch (jobType) { + case INTEREST_CALCULATION -> accrue(row, jobDate, false); + case OD_INTEREST_CALCULATION -> accrue(row, jobDate, true); + case INTEREST_POSTING -> postInterest(row, batchRun, false); + case OD_INTEREST_POSTING -> postInterest(row, batchRun, true); + case COMMISSION_CALCULATION -> accrueCommission(row, batchRun); + case COMMISSION_POSTING -> payCommission(row, batchRun); + case MATURITY_AUTO_CLOSE -> closeAtMaturity(row); + default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType); + } + done++; + } + + // Only at the end. A crediting run is one transaction from start to finish, so a count written + // part-way through would be invisible outside it until the run commits - and writing it from a + // transaction of its own would have two of them contending for the same row. The status says + // POSTING while it works, which is what a clerk actually needs to know. + batchRun.recordProgress(done); + } + + /** + * Settles and shuts one matured account, through the same code a clerk's payout goes through. + * + * The row carries no destination and none is taken from it: the account is read again now and its + * own nominated account used. A run generated last week and posted today would otherwise pay into + * whatever was nominated last week, which is exactly the instruction the customer may since have + * changed. + * + * Nothing is caught here. If one account cannot be settled - it was closed in the meantime, a + * lien was placed on it, its nominated account was shut - the whole run rolls back and the clerk + * is told why, which is how every other posting run behaves. A run that quietly settled nine of + * ten accounts would leave somebody to work out which one it was. + */ + private void closeAtMaturity(final BatchRunRow row) { + this.partyWritePlatformService.getObject().closeAtMaturity(row.getPartyId()); + } + + /** + * Parks the calculated interest against the account and records how far it is calculated through. + * No ledger entry: nothing has moved yet, and the customer's balance is untouched. + * + * The through-date is checked again here, not just when the row was worked out. Generating a run + * does not stamp anything, so two runs can be generated over the same period and each would look + * valid on its own - posting both would earn the same interest twice. Whichever posts first moves + * the through-date past the second's period, and the second finds its work already done. + */ + private void accrue(final BatchRunRow row, final LocalDate jobDate, final boolean odInterest) { + final Party party = partyOf(row.getPartyId()); + + final LocalDate calculatedThrough = party.interestCalculatedThrough(odInterest); + if (calculatedThrough != null && !calculatedThrough.isBefore(row.getToDate())) { + return; + } + + party.accrueInterest(row.getAmount(), jobDate, odInterest); + } + + /** + * Credits the parked interest to the customer's balance and records it on the ledger. + * + * Only what the account is actually holding can be credited: two posting runs generated over the + * same accrued interest would each list it in full, and paying both would credit interest that + * was never earned. + */ + private void postInterest(final BatchRunRow row, final BatchRun batchRun, final boolean odInterest) { + final Party party = partyOf(row.getPartyId()); + if (party.accruedInterest(odInterest).compareTo(row.getAmount()) < 0) { + return; + } + + final BigDecimal newBalance = party.postInterest(row.getAmount(), odInterest); + + // The same interest on the bank's own books - on the party's savings account where it still + // has one, otherwise on DDS Customer Deposits against DDS Interest Expense. + this.ddsLedger.postInterest(party, batchRun.getJobDate(), row.getAmount(), + (odInterest ? "DDS OD interest" : "DDS interest") + ", batch run #" + batchRun.getId()); + + final String transactionType = odInterest ? "OD_INTEREST_POST" : "INTEREST_POST"; + this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), batchRun.getJobDate(), transactionType, + DIRECTION_CREDIT, null, null, null, row.getAmount(), null, null, "DDS batch run #" + batchRun.getId(), newBalance)); + } + + /** + * Adds what the agent earned to what they are owed, and withholds the TDS that went with it. + * Guarded by the collections themselves: each is marked with the run that counted it, so none is + * paid twice however many runs are generated over it. + */ + private void accrueCommission(final BatchRunRow row, final BatchRun batchRun) { + final Agent agent = agentOf(row.getAgentId()); + final LocalDate jobDate = batchRun.getJobDate(); + + // The collections this row was worked out on are marked as counted by this run, so no later run + // counts them again. What is still unmarked has to be exactly what was previewed: nothing means + // another run got there first; anything else means receipts were applied since Generate, and + // the figure on screen is no longer the figure that would be paid. + final BigDecimal stillUncounted = (BigDecimal) this.jdbcTemplate + .queryForMap(UNCOMMISSIONED_COLLECTIONS_SQL, agent.getId(), row.getToDate()).get("volume"); // NOSONAR + if (stillUncounted == null || stillUncounted.compareTo(BigDecimal.ZERO) <= 0) { + return; + } + if (row.getBaseAmount() == null || stillUncounted.compareTo(row.getBaseAmount()) != 0) { + throw new PlatformDataIntegrityException("error.msg.dds.commission.run.out.of.date", + "Collections for " + agent.getAgentCode() + " have changed since this run was generated (" + row.getBaseAmount() + + " then, " + stillUncounted + " now). Discard it and generate it again."); + } + this.jdbcTemplate.update(MARK_COLLECTIONS_COMMISSIONED_SQL, batchRun.getId(), agent.getId(), row.getToDate()); // NOSONAR + + final BigDecimal tds = row.getTdsAmount() == null ? BigDecimal.ZERO : row.getTdsAmount(); + agent.accrueCommission(row.getAmount(), tds, jobDate); + } + + /** + * Pays the agent what they are owed and records it on the ledger. + * + * The agent's DDS balance is deliberately not touched: that figure is cash collected from + * customers and not yet handed to a branch, which commission has nothing to do with. The entry is + * recorded against the balance as it stands, so the ledger reads consistently. + */ + private void payCommission(final BatchRunRow row, final BatchRun batchRun) { + final Agent agent = agentOf(row.getAgentId()); + // Only what is actually owed can be paid: two posting runs generated over the same owed + // figure would each list it in full, and paying both would pay commission twice. + if (agent.getCommission().compareTo(row.getAmount()) < 0) { + return; + } + agent.payOutCommission(row.getAmount()); + + creditCommissionToSavings(agent, row.getAmount(), batchRun); + + this.partyTransactionRepository.save(new PartyTransaction(null, agent, batchRun.getJobDate(), "COMMISSION_POST", DIRECTION_CREDIT, + null, null, agent.getAgentSavingAccountNo(), row.getAmount(), null, null, "DDS batch run #" + batchRun.getId(), + agent.getAgentCurrentBalance())); + } + + /** + * Puts the commission into the agent's own savings account. + * + * Until this ran, paying commission only zeroed a figure on the agent row: the money was "paid" + * inside DDS and never arrived anywhere the agent could draw on, and no ledger entry was made. + * + * The account is checked again here rather than trusted from when it was chosen. It may have been + * closed since, and agents on the books before those checks existed still carry accounts that + * never passed them. Every reason it cannot take the credit is named, because the alternative is + * what this replaced: the deposit was simply attempted, and the savings module answered with + * "Transaction is not allowed. Account is not active" - true, but it tells an officer nothing + * about which agent, which account, or what to do about it. + * + * Throwing rolls the whole run back. A commission run that cannot pay one agent must not half-pay + * the rest. + */ + private void creditCommissionToSavings(final Agent agent, final BigDecimal amount, final BatchRun batchRun) { + final String accountNo = agent.getAgentSavingAccountNo(); + if (accountNo == null || accountNo.isBlank()) { + throw new AgentCommissionAccountUnusableException(agent.getAgentCode(), "no savings account is on file for them"); + } + + // The account's own row, not the entity: loading the entity would drag its product in with it, + // and the ledger loads the account properly for itself anyway. + final List> rows = this.jdbcTemplate.queryForList(COMMISSION_ACCOUNT_SQL, accountNo); // NOSONAR + if (rows.isEmpty()) { + throw new AgentCommissionAccountUnusableException(agent.getAgentCode(), + "no account with the number " + accountNo + " exists, so the commission has nowhere to go"); + } + + final Map account = rows.get(0); + final int status = ((Number) account.get("status_enum")).intValue(); + final int depositType = ((Number) account.get("deposit_type_enum")).intValue(); + final Long productId = ((Number) account.get("product_id")).longValue(); + + if (depositType != SAVINGS_DEPOSIT_TYPE) { + throw new AgentCommissionAccountUnusableException(agent.getAgentCode(), "account " + accountNo + " is a " + + (depositType == FIXED_DEPOSIT_TYPE ? "fixed" : "recurring") + " deposit, which cannot take a commission credit." + + " Set an ordinary savings account for this agent"); + } + + final List ddsProduct = this.jdbcTemplate.queryForList(DDS_DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR + if (!ddsProduct.isEmpty() && productId.equals(ddsProduct.get(0))) { + throw new AgentCommissionAccountUnusableException(agent.getAgentCode(), "account " + accountNo + + " is a DDS scheme account belonging to a customer, so commission paid into it would be counted as their" + + " deposits. Set an ordinary savings account for this agent"); + } + + // Active or dormant: a dormant account is still open and a deposit wakes it up. Everything + // else - awaiting approval, closed, rejected - cannot take one, and this says which it is. + if (status != ACTIVE_SAVINGS_STATUS && status != DORMANT_SAVINGS_STATUS) { + throw new AgentCommissionAccountUnusableException(agent.getAgentCode(), + "account " + accountNo + " is " + statusName(status) + " rather than active, so it cannot take a deposit yet"); + } + + this.ddsLedger.payCommission(((Number) account.get("id")).longValue(), batchRun.getJobDate(), amount, + "DDS commission, batch run #" + batchRun.getId()); + } + + /** Fineract's savings statuses, named the way the screens name them. */ + private String statusName(final int status) { + return switch (status) { + case 100 -> "still awaiting approval"; + case 200 -> "approved but not yet activated"; + case 303 -> "dormant"; + case 304 -> "escheated"; + case 400 -> "withdrawn"; + case 500 -> "rejected"; + case 600 -> "closed"; + default -> "in state " + status; + }; + } + + // --------------------------------------------------------------------------------------------- + // Automatic posting - the Account Type screen's "Auto Interest Post" setting + // --------------------------------------------------------------------------------------------- + + /** + * Posts interest for every scheme that is due today. + * + * A scheme that posts automatically has decided it needs no review, so this does both halves in + * one go: it calculates the interest since the last run and then posts it into the customers' + * balances. Both halves go through the same code the screen's Post button uses, and both leave a + * run behind, so an automatic posting is as visible and as auditable as a manual one. + * + * Runs are scoped to one scheme at a time, because the posting day belongs to the scheme. + */ + @Transactional + @Override + public int postDueAutoInterest(final LocalDate businessDate) { + int posted = 0; + + for (final DdsAccountType accountType : this.ddsAccountTypeRepository.findDueForAutoInterestPost()) { + if (!isDueOn(accountType, businessDate)) { + continue; + } + + final String subType = accountType.getAccountTypeCode(); + calculateAndPost(INTEREST_CALCULATION, businessDate, subType); + calculateAndPost(INTEREST_POSTING, businessDate, subType); + posted++; + } + + return posted; + } + + /** + * Whether this scheme's posting day falls on the given date. + * + * A day beyond the length of the month means the end of that month: a scheme set to 31 posts on + * 31 January, 28 February and 30 April. Without that, February would simply be skipped and the + * account would lose a month's interest every year. + */ + private boolean isDueOn(final DdsAccountType accountType, final LocalDate date) { + if (!monthIsDue(accountType.getAutoInterestPostWhen(), date)) { + return false; + } + final int configuredDay = accountType.getAutoInterestPostDay(); + final int effectiveDay = Math.min(configuredDay, date.lengthOfMonth()); + return date.getDayOfMonth() == effectiveDay; + } + + /** + * Whether the given month is one this frequency posts in. + * + * Monthly is every month. The rest follow the Indian financial year, so quarters close in June, + * September, December and March, half-years in September and March, and the year in March. + *

+ * Every scheme in use today is Monthly; the others are here so the setting is not silently + * ignored, and the month choice for them is worth confirming with the business before any scheme + * relies on it. + */ + private boolean monthIsDue(final String frequency, final LocalDate date) { + final int month = date.getMonthValue(); + return switch (frequency == null ? "Monthly" : frequency) { + case "Quarterly" -> month == 6 || month == 9 || month == 12 || month == 3; + case "Half Yearly" -> month == 9 || month == 3; + case "Yearly" -> month == 3; + default -> true; + }; + } + + /** One half of an automatic posting: work the rows out, save them, then apply them. */ + /** + * Brings every matured account's interest up to its maturity date. + * + * Interest otherwise arrives only when a posting run happens, which is monthly, set per scheme, + * and can be switched off - so a term ending between two runs leaves its last stretch uncredited + * and a payout made in the meantime pays the customer less than they earned. Every account on a + * scheme with automatic posting turned off had exactly that gap. + * + * Maturity is the one date that cannot wait for the next run, so it is done here, on the night + * after each account's term ends, whatever its scheme says about automatic posting. By the time + * the account reaches the clerk's list in the morning it is already whole, and the figure on + * screen is the figure that will be paid. + */ + @Transactional + @Override + public int postMaturityInterest(final LocalDate businessDate) { + int posted = 0; + for (final Party party : this.partyRepository.findMaturedWithInterestOutstanding(businessDate)) { + // Each account's last stretch of interest is its own slip. + final BigDecimal paid = this.ddsLedger.withVoucher(DdsVoucherService.TYPE_INTEREST, DdsVoucherService.REF_PARTY, + party.getId(), () -> postInterestThrough(party, party.getMaturityDate())); + if (paid.compareTo(BigDecimal.ZERO) > 0) { + posted++; + } + } + return posted; + } + + /** The holder's own KYC, read from the bank's record rather than anything DDS keeps of its own. */ + private static final String KYC_VERIFIED_SQL = "select count(*) from m_client_kyc " + + "where client_id = ? and kyc_status = 300 and (expiry_date is null or expiry_date >= current_date)"; + + private static final int MAJORITY_AGE = 18; + + /** + * Turns over the minors' accounts whose holders have come of age and been identified. + * + * A minor's account is operated by a guardian because a minor cannot contract. At eighteen that + * stops being true: the customer holds the account in their own right and the guardian's + * authority ends. Left as it was, the bank would go on dealing with somebody who no longer stands + * for them. + * + * The birthday alone is not enough. An eighteen-year-old the bank has never identified is somebody + * it cannot pay, so the turnover waits on their own KYC being verified - and until it is, the + * payout refuses while collections carry on. Nothing is written for an account still waiting. + */ + @Transactional + @Override + public int promoteMinorsWhoHaveComeOfAge(final LocalDate businessDate) { + final LocalDate bornOnOrBefore = businessDate.minusYears(MAJORITY_AGE); + int promoted = 0; + for (final Party party : this.partyRepository.findMinorsWhoHaveComeOfAge(bornOnOrBefore)) { + final Integer verified = this.jdbcTemplate.queryForObject(KYC_VERIFIED_SQL, Integer.class, party.clientId()); // NOSONAR + if (verified == null || verified == 0) { + continue; + } + party.becomeAdult(); + this.partyRepository.save(party); + promoted++; + } + return promoted; + } + + /** + * Works out a single account's interest up to a date and credits it, returning what was paid. + * + * The same daily-product arithmetic the batch uses - the calculation lives in one class so the + * figure cannot depend on which route asked for it. What differs is only the reach: a run works + * to a common job date across many accounts, this works to one account's own date. + * + * Returns zero, having done nothing, when interest is already current or the sum rounds away. + * Nothing is written in that case - an account that owes nothing should not carry a nil posting. + */ + @Transactional + @Override + public BigDecimal postInterestThrough(final Party party, final LocalDate through) { + if (through == null) { + return BigDecimal.ZERO; + } + + // Interest already calculated but never posted is the customer's too. Without this, an account + // closed between a calculation run and its posting run would shut with that interest still + // parked against it - calculated through the day, so not counted again below, and never paid. + final BigDecimal alreadyCalculated = postCalculatedInterest(party, through); + + final BigDecimal rate = party.getInterestRate(); + if (rate == null || rate.compareTo(BigDecimal.ZERO) <= 0) { + return alreadyCalculated; + } + + final LocalDate accrueFrom = party.interestCalculatedThrough(false) != null ? party.interestCalculatedThrough(false) + : party.getOpeningDate(); + if (!accrueFrom.isBefore(through)) { + return alreadyCalculated; + } + + final List movements = this.partyTransactionRepository.findForPartiesUpTo(List.of(party.getId()), through); + final BigDecimal dailyProduct = DdsInterestCalculation.dailyProduct(party.getOpeningBalance(), movementsOf(movements), accrueFrom, + through); + final BigDecimal interest = DdsInterestCalculation.interestOn(dailyProduct, rate); + if (interest.compareTo(BigDecimal.ZERO) <= 0) { + return alreadyCalculated; + } + + party.accrueInterest(interest, through, false); + final BigDecimal newBalance = party.postInterest(interest, false); + this.partyRepository.saveAndFlush(party); + + // The same interest on the bank's own books, wherever the party's account is kept. + this.ddsLedger.postInterest(party, through, interest, "DDS interest to maturity"); + + this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), through, "INTEREST_POST", DIRECTION_CREDIT, null, + null, null, interest, null, null, "Interest to maturity", newBalance)); + + return alreadyCalculated.add(interest); + } + + @Override + public BigDecimal interestNotYetPosted(final Party party, final LocalDate through) { + final BigDecimal calculated = party.accruedInterest(false) == null ? BigDecimal.ZERO : party.accruedInterest(false); + final BigDecimal rate = party.getInterestRate(); + if (through == null || rate == null || rate.compareTo(BigDecimal.ZERO) <= 0) { + return calculated; + } + final LocalDate accrueFrom = party.interestCalculatedThrough(false) != null ? party.interestCalculatedThrough(false) + : party.getOpeningDate(); + if (accrueFrom == null || !accrueFrom.isBefore(through)) { + return calculated; + } + final List movements = this.partyTransactionRepository.findForPartiesUpTo(List.of(party.getId()), through); + final BigDecimal dailyProduct = DdsInterestCalculation.dailyProduct(party.getOpeningBalance(), movementsOf(movements), accrueFrom, + through); + return calculated.add(DdsInterestCalculation.interestOn(dailyProduct, rate)); + } + + /** + * Credits interest a calculation run has already worked out but no posting run has credited yet, + * the same way a posting run would: onto the balance, onto the bank's books, and into the account's + * history. + */ + private BigDecimal postCalculatedInterest(final Party party, final LocalDate on) { + final BigDecimal pending = party.accruedInterest(false); + if (pending == null || pending.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } + final BigDecimal newBalance = party.postInterest(pending, false); + this.partyRepository.saveAndFlush(party); + this.ddsLedger.postInterest(party, on, pending, "DDS interest calculated earlier, posted on closure"); + this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), on, "INTEREST_POST", DIRECTION_CREDIT, null, + null, null, pending, null, null, "Interest calculated earlier, posted on closure", newBalance)); + return pending; + } + + private void calculateAndPost(final String jobType, final LocalDate jobDate, final String subType) { + final BatchRun batchRun = new BatchRun(jobType, "All", jobDate, 1L, Long.MAX_VALUE, 1L, Long.MAX_VALUE, LocalDate.now(), + subType); + + if (INTEREST_CALCULATION.equals(jobType)) { + generateInterestRows(batchRun, 1L, Long.MAX_VALUE, 1L, Long.MAX_VALUE, jobDate, false, null, subType); + } else { + generateInterestPostingRows(batchRun, 1L, Long.MAX_VALUE, 1L, Long.MAX_VALUE, jobDate, false, null, subType); + } + + this.batchRunRepository.saveAndFlush(batchRun); + + // A run that found nothing still gets recorded and marked posted, so the log shows the job + // looked and there was nothing to do - rather than leaving a run that reads as unfinished. + applyRows(batchRun); + batchRun.markPosted(LocalDate.now()); + this.batchRunRepository.saveAndFlush(batchRun); + } + + private Party partyOf(final Long partyId) { + return this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId)); + } + + private Agent agentOf(final Long agentId) { + return this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId)); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/DdsBatchPostingRunner.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/DdsBatchPostingRunner.java new file mode 100644 index 000000000..1c9a6460a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/DdsBatchPostingRunner.java @@ -0,0 +1,106 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.batch.service; + +import java.time.LocalDate; +import java.util.HashMap; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.scheduling.annotation.Async; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +/** + * Runs a posting batch away from the request that asked for it. + * + * Posting credits every account on the run one at a time, and each credit costs what a savings + * deposit costs - the account is loaded with its transactions and its running balances worked out + * again. Measured on this build, four hundred accounts carrying ninety days of collections took two + * minutes, and the cost climbs faster than the count: twice the accounts took four and a half times + * as long. A branch of any size would leave the clerk watching a still screen, unable to tell a slow + * run from a hung one, and pressing the button again. + * + * So the request returns as soon as the run is marked under way, and the crediting happens here. The + * arithmetic is untouched - the same rows, the same amounts, the same ledger entries. What changes is + * only who waits for it. + * + * A background thread starts with none of the context a request carries, and three pieces of it + * matter. Without the tenant there is no database to write to at all. Without the business date the + * postings would be dated by the wall clock rather than the day the bank is working on. Without the + * signed-in user every row would fail its not-null audit column. All three are taken from the calling + * thread and put back here. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DdsBatchPostingRunner { + + /** + * Deferred on purpose. The service hands work to this runner and the runner calls back into it, + * so resolving either eagerly would have them waiting on each other at startup. + */ + private final ObjectProvider batchRunService; + + @Async + public void post(final Long batchRunId, final FineractPlatformTenant tenant, final HashMap businessDates, + final String dataSourceContext, final SecurityContext securityContext) { + + ThreadLocalContextUtil.setTenant(tenant); + ThreadLocalContextUtil.setBusinessDates(businessDates); + if (dataSourceContext != null) { + ThreadLocalContextUtil.setDataSourceContext(dataSourceContext); + } + SecurityContextHolder.setContext(securityContext); + + try { + this.batchRunService.getObject().applyAndMarkPosted(batchRunId); + log.info("DDS batch run #{} posted", batchRunId); + } catch (final Exception e) { + // Recorded against the run rather than only logged: a run that dies in the background has + // nowhere else to say so, and a clerk who is not told simply presses the button again. + log.error("DDS batch run #{} failed while posting", batchRunId, e); + markFailedQuietly(batchRunId, e); + } finally { + // The pool hands this thread on to whatever comes next, so everything set above has to go + // - a tenant left behind would have the next run writing to the wrong bank's database. + SecurityContextHolder.clearContext(); + ThreadLocalContextUtil.reset(); + } + } + + /** + * Writes the reason onto the run, in a transaction of its own. + * + * The one that failed has rolled back, so this needs a fresh one - and if even that cannot be + * written there is nothing further to be done but log it, since throwing from here would lose the + * original failure. + */ + private void markFailedQuietly(final Long batchRunId, final Exception cause) { + try { + this.batchRunService.getObject().markPostingFailed(batchRunId, cause.getMessage()); + } catch (final Exception ignored) { + log.error("DDS batch run #{} could not record its failure", batchRunId, ignored); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/starter/DdsBatchConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/starter/DdsBatchConfiguration.java new file mode 100644 index 000000000..0e92c10a6 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/starter/DdsBatchConfiguration.java @@ -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.batch.starter; + +import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository; +import org.apache.fineract.organisation.agent.domain.AgentRepository; +import org.apache.fineract.portfolio.dds.accounting.DdsLedger; +import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountTypeRepository; +import org.apache.fineract.portfolio.dds.batch.domain.BatchRunRepository; +import org.apache.fineract.portfolio.dds.batch.serialization.BatchRunCommandFromApiJsonDeserializer; +import org.apache.fineract.portfolio.dds.batch.service.DdsBatchPostingRunner; +import org.apache.fineract.portfolio.dds.batch.service.BatchRunReadPlatformService; +import org.apache.fineract.portfolio.dds.batch.service.BatchRunReadPlatformServiceImpl; +import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService; +import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformServiceJpaRepositoryImpl; +import org.apache.fineract.portfolio.dds.party.domain.PartyRepository; +import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService; +import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +@Configuration +public class DdsBatchConfiguration { + + @Bean + @ConditionalOnMissingBean(BatchRunReadPlatformService.class) + public BatchRunReadPlatformService batchRunReadPlatformService(JdbcTemplate jdbcTemplate) { + return new BatchRunReadPlatformServiceImpl(jdbcTemplate); + } + + @Bean + @ConditionalOnMissingBean(BatchRunWritePlatformService.class) + public BatchRunWritePlatformService batchRunWritePlatformService(BatchRunCommandFromApiJsonDeserializer fromApiJsonDeserializer, + BatchRunRepository batchRunRepository, PartyRepository partyRepository, AgentRepository agentRepository, + PartyTransactionRepository partyTransactionRepository, CommissionSlabRepository commissionSlabRepository, + DdsAccountTypeRepository ddsAccountTypeRepository, DdsLedger ddsLedger, DdsBatchPostingRunner postingRunner, + JdbcTemplate jdbcTemplate, ObjectProvider partyWritePlatformService) { + return new BatchRunWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, batchRunRepository, partyRepository, + agentRepository, partyTransactionRepository, commissionSlabRepository, ddsAccountTypeRepository, ddsLedger, postingRunner, + jdbcTemplate, partyWritePlatformService); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/api/CollectionEntriesApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/api/CollectionEntriesApiResource.java new file mode 100644 index 000000000..d5d6ca99c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/api/CollectionEntriesApiResource.java @@ -0,0 +1,158 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.collection.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.dds.collection.data.AgentCollectionSummaryData; +import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryReadPlatformService; +import org.springframework.stereotype.Component; + +@Path("/v1/dds/collectionentries") +@Component +@Tag(name = "DDS Collection Entries", description = "What an agent wrote down on their round, before the branch has counted the cash. " + + "These rows move no money: no customer is credited, no agent's cash recorded and no ledger entry written until a clerk settles " + + "the agent's day, and what that writes is an ordinary DDS collection.") +@RequiredArgsConstructor +public class CollectionEntriesApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DDSCOLLECTIONENTRY"; + + private final PlatformSecurityContext context; + private final CollectionEntryReadPlatformService readPlatformService; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final DdsVoucherService ddsVoucherService; + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "An agent's written-down collections for a date", description = "Returns the agent's entries for the date with " + + "what they add up to. The agent's own screen shows it back as the record of their round; the counter reads the same figures " + + "to count cash against. Defaults to today when no date is given.") + public AgentCollectionSummaryData retrieveAgentDay(@QueryParam("agentId") @Parameter(description = "agentId") final Long agentId, + @QueryParam("entryDate") @Parameter(description = "entryDate as yyyy-MM-dd") final String entryDate) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + final LocalDate date = entryDate == null || entryDate.isBlank() ? DateUtils.getBusinessLocalDate() : LocalDate.parse(entryDate); + return readPlatformService.retrieveAgentDay(agentId, date); + } + + @GET + @Path("pending") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Everything an agent still has outstanding", description = "Every pending entry for the agent, whatever day " + + "it was written, with what they add up to - what a cashier's DDS receipt is applied against.") + public AgentCollectionSummaryData retrieveAgentPending(@QueryParam("agentId") @Parameter(description = "agentId") final Long agentId) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return readPlatformService.retrieveAgentPending(agentId); + } + + @GET + @Path("cashreceipts") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Cashier receipts into an agent's GL account", description = "Without voucherNo: the DDS receipts the manager " + + "has approved and nobody has applied. With voucherNo: that voucher on the agent's account, in whatever state, so the " + + "screen can say why it cannot be used.") + public List> retrieveAgentCashReceipts(@QueryParam("agentId") @Parameter(description = "agentId") final Long agentId, + @QueryParam("voucherNo") @Parameter(description = "voucherNo") final String voucherNo, + @QueryParam("includeWaiting") @Parameter(description = "also list receipts still awaiting the manager") final Boolean includeWaiting) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return readPlatformService.retrieveAgentCashReceipts(agentId, voucherNo, Boolean.TRUE.equals(includeWaiting)); + } + + @GET + @Path("cashreceipts/{receiptId}/glentries") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "A cashier receipt's own GL entries", description = "The receipt's Dr/Cr lines, read under its own transaction id.") + public List> retrieveCashReceiptGlEntries( + @PathParam("receiptId") @Parameter(description = "receiptId") final Long receiptId) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return readPlatformService.retrieveCashReceiptGlEntries(receiptId); + } + + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Write down collections", description = "Mandatory: agentId, entryDate, entries (array of {partyCode, amount, " + + "transactionMode, chequeNo, tokenNo, description}). Nothing is credited and no cash is recorded - this is the agent's note, " + + "and it stands on its own until the day is settled.") + public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) { + final CommandWrapper commandRequest = new CommandWrapperBuilder() // + .createDdsCollectionEntries() // + .withJson(apiRequestBodyAsJson) // + .build(); + return commandsSourceWritePlatformService.logCommandSource(commandRequest); + } + + @POST + @Path("settle") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Settle an agent's day", description = "Mandatory: agentId, and either entryDate or cashierDepositRequestIds " + + "with entryDates. With entryDate, posts every outstanding entry for that agent and date as a real collection. With " + + "cashierDepositRequestIds (approved DDS receipts on the agent's GL account, each used once) and entryDates (yyyy-MM-dd), " + + "applies the receipts to every outstanding entry on those days - only if they cover them exactly. All or none either way. " + + "No amount is accepted: what is settled is what the entries say.") + public CommandProcessingResult settle(@Parameter(hidden = true) final String apiRequestBodyAsJson) { + final CommandWrapper commandRequest = new CommandWrapperBuilder() // + .settleDdsCollectionEntries() // + .withJson(apiRequestBodyAsJson) // + .build(); + final Long agentId = DdsVoucherService.idIn(apiRequestBodyAsJson, "agentId"); + return ddsVoucherService.submitHoldingVoucher(DdsVoucherService.TYPE_APPLY_RECEIPT, DdsVoucherService.REF_AGENT, agentId, + () -> ddsVoucherService.amountPendingFor(agentId), () -> commandsSourceWritePlatformService.logCommandSource(commandRequest)); + } + + @DELETE + @Path("{entryId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Take back a written-down collection", description = "Cancels an entry that has not been settled. A settled " + + "entry stands behind a real credit and is reversed on the ledger, not withdrawn here.") + public CommandProcessingResult cancel(@PathParam("entryId") @Parameter(description = "entryId") final Long entryId) { + final CommandWrapper commandRequest = new CommandWrapperBuilder() // + .cancelDdsCollectionEntry(entryId) // + .withJson("{}") // + .build(); + return commandsSourceWritePlatformService.logCommandSource(commandRequest); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/domain/CollectionEntryRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/domain/CollectionEntryRepository.java new file mode 100644 index 000000000..48cbb7934 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/domain/CollectionEntryRepository.java @@ -0,0 +1,79 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.collection.domain; + +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CollectionEntryRepository extends JpaRepository { + + /** + * What this agent still has outstanding on this date - the list a clerk counts the cash against, + * and the list the agent's own screen shows back to them. + * + * Ordered by id, which is the order they were written in. An agent checking their round against + * their bag reads it the way they walked it. + */ + @Query("select e from CollectionEntry e where e.agent.id = :agentId and e.entryDate = :entryDate " + + "and e.status = 'PENDING' order by e.id") + List findPendingFor(@Param("agentId") Long agentId, @Param("entryDate") LocalDate entryDate); + + /** + * Everything this agent still has outstanding, whatever day it was written on. + * + * The day's cash can reach the counter the day after the round - written on Friday, brought in on + * Saturday - so a receipt is applied against every outstanding line, not one date's. Oldest first, + * the order the money was collected in. + */ + @Query("select e from CollectionEntry e where e.agent.id = :agentId and e.status = 'PENDING' order by e.entryDate, e.id") + List findAllPendingFor(@Param("agentId") Long agentId); + + /** + * Everything written for this agent on this date, settled or not. + * + * Separate from the pending list because the two answer different questions. A clerk counting + * cash wants only what is outstanding; an agent looking back at their day wants the whole day, + * including what has already been handed in. + */ + @Query("select e from CollectionEntry e where e.agent.id = :agentId and e.entryDate = :entryDate order by e.id") + List findAllFor(@Param("agentId") Long agentId, @Param("entryDate") LocalDate entryDate); + + /** + * Whether this account already has money written against it on this date. + * + * A daily scheme takes one instalment a day, so a second line for the same account on the same + * day is nearly always the same collection entered twice - a dropped connection, a retried tap. + * Caught here rather than let through, because two lines become two credits the moment a clerk + * confirms, and by then the customer's balance is wrong in a way only a reversal can fix. + */ + @Query("select count(e) from CollectionEntry e where e.party.id = :partyId and e.entryDate = :entryDate " + + "and e.status <> 'CANCELLED'") + long countLiveEntriesFor(@Param("partyId") Long partyId, @Param("entryDate") LocalDate entryDate); + + /** + * What is written down for this account and not yet settled. Counted with what has been settled + * when a new line is checked against the scheme, so arrears cleared on an entry still waiting at + * the counter cannot be cleared a second time on the next one. + */ + @Query("select coalesce(sum(e.amount), 0) from CollectionEntry e where e.party.id = :partyId and e.status = 'PENDING'") + java.math.BigDecimal sumPendingFor(@Param("partyId") Long partyId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/handler/CancelCollectionEntryCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/handler/CancelCollectionEntryCommandHandler.java new file mode 100644 index 000000000..a07113178 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/handler/CancelCollectionEntryCommandHandler.java @@ -0,0 +1,46 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.collection.handler; + +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "DDSCOLLECTIONENTRY", action = "CANCEL") +public class CancelCollectionEntryCommandHandler implements NewCommandSourceHandler { + + private final CollectionEntryWritePlatformService writePlatformService; + + @Autowired + public CancelCollectionEntryCommandHandler(final CollectionEntryWritePlatformService writePlatformService) { + this.writePlatformService = writePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.cancelEntry(command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/handler/CreateCollectionEntriesCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/handler/CreateCollectionEntriesCommandHandler.java new file mode 100644 index 000000000..4a8057236 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/handler/CreateCollectionEntriesCommandHandler.java @@ -0,0 +1,46 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.collection.handler; + +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "DDSCOLLECTIONENTRY", action = "CREATE") +public class CreateCollectionEntriesCommandHandler implements NewCommandSourceHandler { + + private final CollectionEntryWritePlatformService writePlatformService; + + @Autowired + public CreateCollectionEntriesCommandHandler(final CollectionEntryWritePlatformService writePlatformService) { + this.writePlatformService = writePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.createEntries(command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/handler/SettleCollectionEntriesCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/handler/SettleCollectionEntriesCommandHandler.java new file mode 100644 index 000000000..c13722512 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/handler/SettleCollectionEntriesCommandHandler.java @@ -0,0 +1,46 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.collection.handler; + +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "DDSCOLLECTIONENTRY", action = "SETTLE") +public class SettleCollectionEntriesCommandHandler implements NewCommandSourceHandler { + + private final CollectionEntryWritePlatformService writePlatformService; + + @Autowired + public SettleCollectionEntriesCommandHandler(final CollectionEntryWritePlatformService writePlatformService) { + this.writePlatformService = writePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.settleAgentDay(command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/serialization/CollectionEntryCommandFromApiJsonDeserializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/serialization/CollectionEntryCommandFromApiJsonDeserializer.java new file mode 100644 index 000000000..9aa4b26a5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/serialization/CollectionEntryCommandFromApiJsonDeserializer.java @@ -0,0 +1,204 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.collection.serialization; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.reflect.TypeToken; +import java.lang.reflect.Type; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; +import org.apache.fineract.infrastructure.core.exception.InvalidJsonException; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.portfolio.dds.calculation.DdsSchedule; +import org.apache.fineract.portfolio.dds.transaction.domain.DdsTransactionMode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * What a written-down collection has to say before it is worth writing down. + * + * Deliberately narrow. This is the agent's own note, not a posting, so the questions asked are only + * the ones that can be answered away from the branch: is there an account named, an amount, and a + * form the money came in. Whether the cash is really there is settled at the counter, and no amount + * of checking here would bring that forward. + */ +@Component +public final class CollectionEntryCommandFromApiJsonDeserializer { + + private static final String RESOURCE = "ddscollectionentry"; + + private static final String ENTRIES = "entries"; + + private static final Set SUPPORTED_CREATE_PARAMETERS = new HashSet<>( + Arrays.asList("agentId", "entryDate", ENTRIES, "dateFormat", "locale")); + + private static final Set SUPPORTED_ENTRY_PARAMETERS = new HashSet<>( + Arrays.asList("partyCode", "amount", "advanceDays", "arrearsDays", "transactionMode", "chequeNo", "tokenNo", "description")); + + private static final Set SUPPORTED_SETTLE_PARAMETERS = new HashSet<>( + Arrays.asList("agentId", "entryDate", "cashierDepositRequestIds", "entryDates", "dateFormat", "locale")); + + private final FromJsonHelper fromApiJsonHelper; + + @Autowired + public CollectionEntryCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) { + this.fromApiJsonHelper = fromApiJsonHelper; + } + + public void validateForCreate(final String json) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + final Type typeOfMap = new TypeToken>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_CREATE_PARAMETERS); + + final List errors = new ArrayList<>(); + final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE); + final JsonElement element = this.fromApiJsonHelper.parse(json); + + v.reset().parameter("agentId").value(this.fromApiJsonHelper.extractLongNamed("agentId", element)).notNull() + .integerGreaterThanZero(); + v.reset().parameter("entryDate").value(this.fromApiJsonHelper.extractLocalDateNamed("entryDate", element)).notNull(); + + if (!this.fromApiJsonHelper.parameterExists(ENTRIES, element)) { + v.reset().parameter(ENTRIES).value(null).notNull(); + } else { + final var rows = element.getAsJsonObject().getAsJsonArray(ENTRIES); + v.reset().parameter(ENTRIES).value(rows).jsonArrayNotEmpty(); + + for (int i = 0; i < rows.size(); i++) { + final JsonObject row = rows.get(i).getAsJsonObject(); + this.fromApiJsonHelper.checkForUnsupportedParameters(row, SUPPORTED_ENTRY_PARAMETERS); + + v.reset().parameter(ENTRIES).parameterAtIndexArray("partyCode", i).value(stringOf(row, "partyCode")).notBlank(); + + // Days ahead or days of arrears, never both - one line is one kind of collection. The + // amount follows from the days, so it is not required when they are given; where it is + // sent as well it has to agree, which the service checks against the scheme. + final Integer advanceDays = integerOf(row, "advanceDays"); + final Integer arrearsDays = integerOf(row, "arrearsDays"); + if (advanceDays != null && arrearsDays != null) { + v.reset().parameter(ENTRIES).failWithCodeAndMessage("advance.and.arrears", + "Row " + (i + 1) + ": a line is either days in advance or days of arrears, not both"); + } + if (advanceDays != null) { + v.reset().parameter(ENTRIES).parameterAtIndexArray("advanceDays", i).value(advanceDays) + .inMinMaxRange(1, DdsSchedule.MAX_ADVANCE_DAYS); + } else if (arrearsDays != null) { + v.reset().parameter(ENTRIES).parameterAtIndexArray("arrearsDays", i).value(arrearsDays) + .inMinMaxRange(1, DdsSchedule.CATCH_UP_DAYS); + } else { + v.reset().parameter(ENTRIES).parameterAtIndexArray("amount", i).value(decimalOf(row)).notNull().positiveAmount(); + } + + final String mode = stringOf(row, "transactionMode"); + v.reset().parameter(ENTRIES).parameterAtIndexArray("transactionMode", i).value(mode).notBlank(); + // The row has to be named in the message: a round is entered a line at a time and the + // agent needs to know which line the screen is complaining about. + v.reset().parameter(ENTRIES).value(mode).ignoreIfNull().matchesRegularExpression(DdsTransactionMode.acceptedPattern(), + "Row " + (i + 1) + ": payment mode must be one of " + DdsTransactionMode.labelsForMessage()); + + v.reset().parameter(ENTRIES).parameterAtIndexArray("chequeNo", i).value(stringOf(row, "chequeNo")).ignoreIfNull() + .notExceedingLengthOf(50); + v.reset().parameter(ENTRIES).parameterAtIndexArray("tokenNo", i).value(stringOf(row, "tokenNo")).ignoreIfNull() + .notExceedingLengthOf(50); + v.reset().parameter(ENTRIES).parameterAtIndexArray("description", i).value(stringOf(row, "description")).ignoreIfNull() + .notExceedingLengthOf(200); + } + } + + throwIfErrors(errors); + } + + public void validateForSettle(final String json) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + final Type typeOfMap = new TypeToken>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_SETTLE_PARAMETERS); + + final List errors = new ArrayList<>(); + final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE); + final JsonElement element = this.fromApiJsonHelper.parse(json); + + v.reset().parameter("agentId").value(this.fromApiJsonHelper.extractLongNamed("agentId", element)).notNull() + .integerGreaterThanZero(); + // The cashier's receipts settle the days picked; without them, one day is named. + if (this.fromApiJsonHelper.parameterExists("cashierDepositRequestIds", element)) { + final var receipts = element.getAsJsonObject().get("cashierDepositRequestIds"); + v.reset().parameter("cashierDepositRequestIds").value(receipts != null && receipts.isJsonArray() ? receipts.getAsJsonArray() : null) + .notNull().jsonArrayNotEmpty(); + final var days = element.getAsJsonObject().get("entryDates"); + v.reset().parameter("entryDates").value(days != null && days.isJsonArray() ? days.getAsJsonArray() : null).notNull() + .jsonArrayNotEmpty(); + if (receipts != null && receipts.isJsonArray()) { + for (int i = 0; i < receipts.getAsJsonArray().size(); i++) { + final var id = receipts.getAsJsonArray().get(i); + v.reset().parameter("cashierDepositRequestIds").parameterAtIndexArray("id", i) + .value(id.isJsonPrimitive() && id.getAsJsonPrimitive().isNumber() ? id.getAsLong() : null).notNull() + .integerGreaterThanZero(); + } + } + if (days != null && days.isJsonArray()) { + for (int i = 0; i < days.getAsJsonArray().size(); i++) { + final var day = days.getAsJsonArray().get(i); + v.reset().parameter("entryDates").parameterAtIndexArray("date", i) + .value(day.isJsonPrimitive() ? day.getAsString() : null).notBlank() + .matchesRegularExpression("\\d{4}-\\d{2}-\\d{2}", "Dates must be yyyy-MM-dd"); + } + } + } else { + v.reset().parameter("entryDate").value(this.fromApiJsonHelper.extractLocalDateNamed("entryDate", element)).notNull(); + } + + // No amount is asked for. What is settled is what the entries say, to the rupee - a figure + // typed here would be a second opinion about a total the entries already give, and the two + // could differ. + throwIfErrors(errors); + } + + private String stringOf(final JsonObject json, final String field) { + return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null; + } + + private Integer integerOf(final JsonObject json, final String field) { + return json.has(field) && !json.get(field).isJsonNull() && !json.get(field).getAsString().isBlank() ? json.get(field).getAsInt() + : null; + } + + private BigDecimal decimalOf(final JsonObject json) { + return json.has("amount") && !json.get("amount").isJsonNull() ? json.get("amount").getAsBigDecimal() : null; + } + + private void throwIfErrors(final List errors) { + if (!errors.isEmpty()) { + throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/service/CollectionEntryReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/service/CollectionEntryReadPlatformService.java new file mode 100644 index 000000000..8a36304a2 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/service/CollectionEntryReadPlatformService.java @@ -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.collection.service; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import org.apache.fineract.portfolio.dds.collection.data.AgentCollectionSummaryData; + +public interface CollectionEntryReadPlatformService { + + /** + * An agent's written-down collections for a date, with what they add up to. + * + * One call serves both screens. The agent's own page shows it back to them as the running record + * of their round; the clerk's settlement screen reads the same figures to count cash against. + * Two calls would be two chances for the two screens to disagree about the same day. + */ + AgentCollectionSummaryData retrieveAgentDay(Long agentId, LocalDate entryDate); + + /** Everything the agent still has outstanding, whatever day it was written. */ + AgentCollectionSummaryData retrieveAgentPending(Long agentId); + + /** The cashier's receipts into the agent's GL account - the applicable ones, or one voucher by number. */ + List> retrieveAgentCashReceipts(Long agentId, String voucherNo, boolean includeWaiting); + + /** A cashier's receipt's own Dr/Cr lines. */ + List> retrieveCashReceiptGlEntries(Long receiptId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/service/CollectionEntryReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/service/CollectionEntryReadPlatformServiceImpl.java new file mode 100644 index 000000000..f588e813a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/service/CollectionEntryReadPlatformServiceImpl.java @@ -0,0 +1,207 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.collection.service; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.domain.JdbcSupport; +import org.apache.fineract.organisation.agent.domain.Agent; +import org.apache.fineract.organisation.agent.domain.AgentRepository; +import org.apache.fineract.organisation.agent.exception.AgentNotFoundException; +import org.apache.fineract.portfolio.dds.collection.data.AgentCollectionSummaryData; +import org.apache.fineract.portfolio.dds.collection.data.CollectionEntryData; +import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntry; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +@RequiredArgsConstructor +public class CollectionEntryReadPlatformServiceImpl implements CollectionEntryReadPlatformService { + + private final JdbcTemplate jdbcTemplate; + private final AgentRepository agentRepository; + + private static final class EntryMapper implements RowMapper { + + String schema() { + // The party is joined in for its code, name and balance: every reader of this list is + // checking a line against a person, and an id on its own tells them nothing. The balance + // is the account's as it stands - these entries have not been posted, so it does not + // include them, and the screens say as much. + return " e.id as id, e.agent_id as agentId, a.agent_code as agentCode, a.agent_name as agentName, " + + " e.party_id as partyId, p.party_code as partyCode, p.party_name as partyName, " + + " p.current_balance as partyBalance, p.daily_deposit_amount as dailyDepositAmount, " + + " e.entry_date as entryDate, e.amount as amount, e.transaction_mode as transactionMode, " + + " e.cheque_no as chequeNo, e.token_no as tokenNo, e.description as description, " + + " e.status as status, e.settled_on as settledOn, e.party_transaction_id as partyTransactionId, " + + " e.advance_days as advanceDays, e.arrears_days as arrearsDays " + + " from m_dds_collection_entry e " + + " join m_dds_agent a on a.id = e.agent_id " + + " join m_dds_party p on p.id = e.party_id"; + } + + @Override + public CollectionEntryData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + return CollectionEntryData.builder() // + .id(rs.getLong("id")) // + .agentId(rs.getLong("agentId")) // + .agentCode(rs.getString("agentCode")) // + .agentName(rs.getString("agentName")) // + .partyId(rs.getLong("partyId")) // + .partyCode(rs.getString("partyCode")) // + .partyName(rs.getString("partyName")) // + .partyBalance(rs.getBigDecimal("partyBalance")) // + .dailyDepositAmount(rs.getBigDecimal("dailyDepositAmount")) // + .entryDate(JdbcSupport.getLocalDate(rs, "entryDate")) // + .amount(rs.getBigDecimal("amount")) // + .transactionMode(rs.getString("transactionMode")) // + .chequeNo(rs.getString("chequeNo")) // + .tokenNo(rs.getString("tokenNo")) // + .description(rs.getString("description")) // + .status(rs.getString("status")) // + .settledOn(JdbcSupport.getLocalDate(rs, "settledOn")) // + .partyTransactionId(JdbcSupport.getLong(rs, "partyTransactionId")) // + .advanceDays(JdbcSupport.getInteger(rs, "advanceDays")) // + .arrearsDays(JdbcSupport.getInteger(rs, "arrearsDays")) // + .build(); + } + } + + /** + * Everything the agent still has outstanding, whatever day it was written - what a cashier's + * receipt is applied against. Same shape as a day, with no date on it. + */ + @Override + public AgentCollectionSummaryData retrieveAgentPending(final Long agentId) { + final Agent agent = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId)); + + final EntryMapper mapper = new EntryMapper(); + final String sql = "select " + mapper.schema() + " where e.agent_id = ? and e.status = '" + CollectionEntry.PENDING + "' " + + " order by e.entry_date, e.id"; + final List entries = this.jdbcTemplate.query(sql, mapper, agentId); + final BigDecimal expected = entries.stream().map(CollectionEntryData::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add); + + return AgentCollectionSummaryData.builder() // + .agentId(agent.getId()) // + .agentCode(agent.getAgentCode()) // + .agentName(agent.getAgentName()) // + .pendingCount(entries.size()) // + .expectedAmount(expected) // + .settledCount(0) // + .settledAmount(BigDecimal.ZERO) // + .entries(entries) // + .build(); + } + + /** + * The cashier's receipts into this agent's GL account. + * + * Without a voucher number: only what can be applied - DDS receipts the manager has approved and + * nobody has applied yet - or, asked to include waiting ones, those still with the manager as well, + * so the counter can see cash that has come in but cannot be applied yet. With one: that voucher on + * this account whatever state it is in, so the screen can say why it cannot be used rather than + * simply not finding it. Read straight off the cashier's own table and never written. + */ + @Override + public List> retrieveAgentCashReceipts(final Long agentId, final String voucherNo, final boolean includeWaiting) { + final Agent agent = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId)); + if (agent.getGlAccount() == null) { + return List.of(); + } + + final StringBuilder sql = new StringBuilder("select d.id as \"id\", d.voucher_no as \"voucherNo\", " + + " to_char(d.txn_date, 'YYYY-MM-DD') as \"txnDate\", d.amount as \"amount\", d.status as \"status\", " + + " cv.code_value as \"entryType\", d.account_no as \"glCode\", g.name as \"glName\", " + + " d.cashier_name as \"cashierName\", d.manager_name as \"managerName\", d.description as \"description\", " + + " d.txn_id as \"txnId\", " + + " exists (select 1 from m_dds_receipt_application_voucher v where v.cashier_deposit_request_id = d.id) as \"applied\" " + + " from m_cashier_deposit_request d " + + " left join m_code_value cv on cv.id = d.entry_type_cv_id " + + " left join acc_gl_account g on g.id = d.gl_account_id " + + " where trim(d.account_no) = ? "); + final Object[] params; + if (voucherNo != null && !voucherNo.isBlank()) { + sql.append(" and d.voucher_no = ? "); + params = new Object[] { agent.getGlAccount().getGlCode(), voucherNo.trim() }; + } else { + sql.append(" and cv.code_value = 'DDS' ") + .append(includeWaiting ? " and d.status not in ('REJECTED', 'CANCELLED') " : " and d.status = 'APPROVED' ") + .append(" and not exists (select 1 from m_dds_receipt_application_voucher v where v.cashier_deposit_request_id = d.id) "); + params = new Object[] { agent.getGlAccount().getGlCode() }; + } + sql.append(" order by d.txn_date, d.id"); + return this.jdbcTemplate.queryForList(sql.toString(), params); // NOSONAR + } + + /** A receipt's own Dr/Cr lines, read under its own transaction id. */ + @Override + public List> retrieveCashReceiptGlEntries(final Long receiptId) { + return this.jdbcTemplate.queryForList("select je.id as \"id\", g.gl_code as \"glCode\", g.name as \"glName\", " // NOSONAR + + " case je.type_enum when 1 then 'CR' else 'DR' end as \"drCr\", je.amount as \"amount\", " + + " to_char(je.entry_date, 'YYYY-MM-DD') as \"entryDate\", je.reversed as \"reversed\" " + + " from acc_gl_journal_entry je join acc_gl_account g on g.id = je.account_id " + + " where je.transaction_id = (select d.txn_id from m_cashier_deposit_request d where d.id = ?) order by je.id", receiptId); + } + + @Override + public AgentCollectionSummaryData retrieveAgentDay(final Long agentId, final LocalDate entryDate) { + // Named first, so an agent id that matches nothing is refused here rather than quietly + // returning an empty day - which reads as "collected nothing", and that is an answer a clerk + // would act on. + final Agent agent = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId)); + + final EntryMapper mapper = new EntryMapper(); + // Cancelled lines are left out. They were taken back before anything was posted, so they are + // not part of the day's record and would only be counted by somebody adding the column up. + final String sql = "select " + mapper.schema() + " where e.agent_id = ? and e.entry_date = ? " + + " and e.status <> '" + CollectionEntry.CANCELLED + "' order by e.id"; + final List entries = this.jdbcTemplate.query(sql, mapper, agentId, entryDate); + + int pendingCount = 0; + int settledCount = 0; + BigDecimal expected = BigDecimal.ZERO; + BigDecimal settled = BigDecimal.ZERO; + + for (final CollectionEntryData entry : entries) { + if (CollectionEntry.PENDING.equals(entry.getStatus())) { + pendingCount++; + expected = expected.add(entry.getAmount()); + } else if (CollectionEntry.SETTLED.equals(entry.getStatus())) { + settledCount++; + settled = settled.add(entry.getAmount()); + } + } + + return AgentCollectionSummaryData.builder() // + .agentId(agent.getId()) // + .agentCode(agent.getAgentCode()) // + .agentName(agent.getAgentName()) // + .entryDate(entryDate) // + .pendingCount(pendingCount) // + .expectedAmount(expected) // + .settledCount(settledCount) // + .settledAmount(settled) // + .entries(entries) // + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/service/CollectionEntryWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/service/CollectionEntryWritePlatformService.java new file mode 100644 index 000000000..9cd94018b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/service/CollectionEntryWritePlatformService.java @@ -0,0 +1,48 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.collection.service; + +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +public interface CollectionEntryWritePlatformService { + + /** + * Writes down what an agent says they collected. Moves no money whatsoever. + * + * Accepts one entry or a round of them. The checks are the ones that can be made without the + * cash in hand - that the account exists, is open, and is this agent's to collect from - because + * an entry the branch will refuse hours later is better refused at the customer's door, while + * the agent is still standing there. + */ + CommandProcessingResult createEntries(JsonCommand command); + + /** Takes back a line that was never posted. Refused once the entry has been settled. */ + CommandProcessingResult cancelEntry(Long entryId, JsonCommand command); + + /** + * Turns an agent's outstanding entries for a date into real credits, all together. + * + * This is the moment the money becomes the bank's business: the clerk has the cash in front of + * them, counted against what the entries add up to. All or none - a settlement that posted half + * a round would leave the branch holding cash it could not account for against a list that no + * longer matched. + */ + CommandProcessingResult settleAgentDay(JsonCommand command); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/service/CollectionEntryWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/service/CollectionEntryWritePlatformServiceJpaRepositoryImpl.java new file mode 100644 index 000000000..7ac74af18 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/service/CollectionEntryWritePlatformServiceJpaRepositoryImpl.java @@ -0,0 +1,476 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.collection.service; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.portfolio.dds.accounting.DdsLedger; +import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService; +import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository; +import org.apache.fineract.portfolio.dds.calculation.DdsSchedule; +import org.apache.fineract.portfolio.dds.transaction.exception.PartyAdvanceAmountMismatchException; +import org.apache.fineract.portfolio.dds.transaction.exception.PartyArrearsDaysExceededException; +import org.apache.fineract.portfolio.dds.transaction.exception.PartyHasNoDailyAmountException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.agent.domain.Agent; +import org.apache.fineract.organisation.agent.domain.AgentRepository; +import org.apache.fineract.organisation.agent.exception.AgentNotFoundException; +import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntry; +import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntryRepository; +import org.apache.fineract.portfolio.dds.collection.exception.CollectionEntryNotFoundException; +import org.apache.fineract.portfolio.dds.collection.exception.CollectionEntryNotPendingException; +import org.apache.fineract.portfolio.dds.collection.exception.NothingToSettleException; +import org.apache.fineract.portfolio.dds.collection.serialization.CollectionEntryCommandFromApiJsonDeserializer; +import org.apache.fineract.portfolio.dds.party.domain.Party; +import org.apache.fineract.portfolio.dds.party.domain.PartyRepository; +import org.apache.fineract.portfolio.dds.party.exception.PartyAlreadyClosedException; +import org.apache.fineract.portfolio.dds.party.exception.PartyNotFoundException; +import org.apache.fineract.portfolio.dds.transaction.domain.DdsTransactionMode; +import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransaction; +import org.apache.fineract.portfolio.dds.transaction.exception.PartyNotCollectedByAgentException; +import org.apache.fineract.portfolio.dds.transaction.service.PartyTransactionWritePlatformService; +import org.springframework.transaction.annotation.Transactional; + +/** + * Writing down collections, taking them back, and turning a day's worth into real credits. + * + * The division of labour here is the whole point of the feature. Everything up to the settlement + * writes rows in one table and touches nothing else - no balance, no ledger, no agent's cash. The + * settlement is where it becomes the bank's business, and what it does there is not its own version + * of a posting but the same one the counter has always used. + */ +@RequiredArgsConstructor +public class CollectionEntryWritePlatformServiceJpaRepositoryImpl implements CollectionEntryWritePlatformService { + + private final CollectionEntryCommandFromApiJsonDeserializer fromApiJsonDeserializer; + private final CollectionEntryRepository collectionEntryRepository; + private final AgentRepository agentRepository; + private final PartyRepository partyRepository; + private final PartyTransactionWritePlatformService partyTransactionWritePlatformService; + private final PartyTransactionRepository partyTransactionRepository; + private final DdsLedger ddsLedger; + private final GLAccountRepository glAccountRepository; + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + + @Transactional + @Override + public CommandProcessingResult createEntries(final JsonCommand command) { + this.fromApiJsonDeserializer.validateForCreate(command.json()); + + final Agent agent = agentOf(command.longValueOfParameterNamed("agentId")); + final LocalDate entryDate = command.localDateValueOfParameterNamed("entryDate"); + + // A round is written on the day, or caught up on afterwards - never ahead of it. + if (entryDate.isAfter(DateUtils.getBusinessLocalDate())) { + throw refused("entry.future.date", "A collection cannot be written down for " + entryDate + ", which has not happened yet."); + } + + final JsonArray rows = command.arrayOfParameterNamed("entries"); + final List written = new ArrayList<>(rows.size()); + final Set partiesInRound = new HashSet<>(); + BigDecimal total = BigDecimal.ZERO; + + for (int i = 0; i < rows.size(); i++) { + final JsonObject row = rows.get(i).getAsJsonObject(); + final Party party = collectableParty(stringOf(row, "partyCode"), agent); + + // One line per account per day. A daily scheme takes one instalment a day, so a second line + // is nearly always the same collection entered twice; more than a day's money is one line + // with days in advance or days of arrears on it. Refused here, not only on the agent's + // screen, because the screen is not the only way in. + if (!partiesInRound.add(party.getId()) || this.collectionEntryRepository.countLiveEntriesFor(party.getId(), entryDate) > 0) { + throw refused("entry.duplicate", party.getPartyCode() + " already has a collection written down for " + entryDate + + ". One line per account per day - use Advance or Previous days to cover more than one day."); + } + + final Integer advanceDays = integerOf(row, "advanceDays"); + final Integer arrearsDays = integerOf(row, "arrearsDays"); + final BigDecimal amount = checkedAmount(party, entryDate, row, advanceDays, arrearsDays); + + final CollectionEntry entry = new CollectionEntry(agent, party, entryDate, amount, + DdsTransactionMode.canonicalOrOriginal(stringOf(row, "transactionMode")), stringOf(row, "chequeNo"), + stringOf(row, "tokenNo"), stringOf(row, "description")); + entry.covering(advanceDays, arrearsDays); + + written.add(this.collectionEntryRepository.saveAndFlush(entry).getId()); + total = total.add(amount); + } + + final Map changes = new LinkedHashMap<>(); + changes.put("entriesWritten", written.size()); + changes.put("totalAmount", total); + changes.put("entryIds", written); + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(agent.getId()).with(changes).build(); + } + + @Transactional + @Override + public CommandProcessingResult cancelEntry(final Long entryId, final JsonCommand command) { + final CollectionEntry entry = this.collectionEntryRepository.findById(entryId) + .orElseThrow(() -> new CollectionEntryNotFoundException(entryId)); + + if (!entry.isPending()) { + throw new CollectionEntryNotPendingException(entryId, entry.getStatus()); + } + + entry.markCancelled(); + this.collectionEntryRepository.saveAndFlush(entry); + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(entryId).build(); + } + + @Transactional + @Override + public CommandProcessingResult settleAgentDay(final JsonCommand command) { + this.fromApiJsonDeserializer.validateForSettle(command.json()); + + // One DDS voucher for the day applied: the agent's account on one side, each customer credited on the other. + return this.ddsLedger.withVoucher(DdsVoucherService.TYPE_APPLY_RECEIPT, DdsVoucherService.REF_AGENT, + command.longValueOfParameterNamed("agentId"), () -> settleWithinVoucher(command)); + } + + private CommandProcessingResult settleWithinVoucher(final JsonCommand command) { + // Named by the cashier's receipts: the cash has already reached the bank and sits on the agent's + // GL account, and settling hands it on from there. Without them, the day is settled the way it + // always was. + if (command.parameterExists(RECEIPT_IDS)) { + return settleAgainstReceipts(command); + } + + final Agent agent = agentOf(command.longValueOfParameterNamed("agentId")); + final LocalDate entryDate = command.localDateValueOfParameterNamed("entryDate"); + + final List pending = this.collectionEntryRepository.findPendingFor(agent.getId(), entryDate); + if (pending.isEmpty()) { + throw new NothingToSettleException(agent.getAgentCode(), entryDate.toString()); + } + + final LocalDate settledOn = DateUtils.getBusinessLocalDate(); + BigDecimal total = BigDecimal.ZERO; + + for (final CollectionEntry entry : pending) { + // Asked again, now. An account can be closed, or moved to another agent, between the + // round and the counter - and this is the moment the money actually lands, so this is the + // moment the account has to be fit to receive it. + final Party party = collectableParty(entry.getParty().getPartyCode(), agent); + + // Posted through the counter's own code, not a copy of it. The entry is the note; what + // this writes is an ordinary collection, indistinguishable on the ledger from one typed + // at the branch - because that is exactly what it is. + final PartyTransaction transaction = this.partyTransactionWritePlatformService.postSingleCollection(agent, party, + entry.getEntryDate(), entry.getAmount(), entry.getTransactionMode(), null, null, entry.getChequeNo(), + entry.getTokenNo(), entry.getDescription()); + + entry.markSettled(settledOn, transaction.getId()); + this.collectionEntryRepository.save(entry); + total = total.add(entry.getAmount()); + } + + final Map changes = new LinkedHashMap<>(); + changes.put("entriesSettled", pending.size()); + changes.put("totalAmount", total); + changes.put("settledOn", settledOn); + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(agent.getId()).with(changes).build(); + } + + private static final String RECEIPT_IDS = "cashierDepositRequestIds"; + private static final String ENTRY_DATES = "entryDates"; + + /** + * A receipt as the cashier left it, locked for the length of this settlement so the same voucher + * cannot be applied twice by two clerks at once. Read only - the cashier's row is never written here. + */ + private static final String RECEIPT_SQL = "select d.id, d.voucher_no, d.account_no, d.amount, d.status, d.gl_account_id, " + + " (select cv.code_value from m_code_value cv where cv.id = d.entry_type_cv_id) as entry_type " + + " from m_cashier_deposit_request d where d.id = ? for update"; + + private static final String RECEIPT_USED_SQL = "select count(*) from m_dds_receipt_application_voucher where cashier_deposit_request_id = ?"; + + private static final String INSERT_APPLICATION_SQL = "insert into m_dds_receipt_application " + + " (agent_id, applied_on, total_amount, entry_count, gl_account_id, journal_transaction_id, createdby_id, created_date) " + + " values (?, ?, ?, ?, ?, null, ?, current_timestamp) returning id"; + + private static final String INSERT_APPLICATION_VOUCHER_SQL = "insert into m_dds_receipt_application_voucher " + + " (application_id, cashier_deposit_request_id, voucher_no, amount) values (?, ?, ?, ?)"; + + private static final String SET_JOURNAL_SQL = "update m_dds_receipt_application set journal_transaction_id = ? where id = ?"; + + /** + * Applies the cashier's DDS receipts to the agent's entries for the days picked. + * + * The same as Apply on a Share Collection receipt, for any number of vouchers and days: a day's cash + * can arrive in two visits, and two days' in one. Every voucher has to be the agent's - a DDS + * receipt, approved, into this agent's own GL account - and applied once. Together they have to + * cover the picked days' entries exactly: what the agent wrote down is what the customers are owed, + * so vouchers short of it leave somebody uncredited and vouchers over it leave money on the agent's + * account nobody can place. All of it or none. + */ + private CommandProcessingResult settleAgainstReceipts(final JsonCommand command) { + final Agent agent = agentOf(command.longValueOfParameterNamed("agentId")); + final GLAccount agentAccount = agent.getGlAccount(); + if (agentAccount == null) { + throw refused("agent.no.gl", agent.getAgentCode() + " has no GL account, so no cashier receipt can be theirs."); + } + + // The vouchers. + final JsonArray receiptIds = command.arrayOfParameterNamed(RECEIPT_IDS); + final Set seen = new LinkedHashSet<>(); + final List> receipts = new ArrayList<>(); + BigDecimal received = BigDecimal.ZERO; + Long receiptAccountId = null; + + for (int i = 0; i < receiptIds.size(); i++) { + final Long receiptId = receiptIds.get(i).getAsLong(); + if (!seen.add(receiptId)) { + continue; + } + final List> rows = this.jdbcTemplate.queryForList(RECEIPT_SQL, receiptId); // NOSONAR + if (rows.isEmpty()) { + throw refused("receipt.not.found", "No cashier receipt with id " + receiptId + "."); + } + final Map receipt = rows.get(0); + final String voucherNo = (String) receipt.get("voucher_no"); + + if (!"DDS".equals(receipt.get("entry_type"))) { + throw refused("receipt.wrong.purpose", "Voucher " + voucherNo + " was not taken as a DDS receipt. Ask the cashier to " + + "correct the entry type at Cashier > Transaction Entry."); + } + if (!"APPROVED".equals(receipt.get("status"))) { + throw refused("receipt.not.approved", "Voucher " + voucherNo + " is " + receipt.get("status") + + ". It can be applied once the manager has approved it."); + } + if (!agentAccount.getGlCode().equals(String.valueOf(receipt.get("account_no")).trim())) { + throw refused("receipt.other.account", "Voucher " + voucherNo + " was paid into " + receipt.get("account_no") + ", not " + + agent.getAgentCode() + "'s account " + agentAccount.getGlCode() + "."); + } + final Long used = this.jdbcTemplate.queryForObject(RECEIPT_USED_SQL, Long.class, receiptId); // NOSONAR + if (used != null && used > 0) { + throw refused("receipt.already.applied", "Voucher " + voucherNo + " has already been applied."); + } + + final Long accountId = receipt.get("gl_account_id") == null ? agentAccount.getId() + : ((Number) receipt.get("gl_account_id")).longValue(); + if (receiptAccountId != null && !receiptAccountId.equals(accountId)) { + throw refused("receipt.mixed.accounts", "The vouchers picked were paid into different GL accounts. Apply them separately."); + } + receiptAccountId = accountId; + received = received.add((BigDecimal) receipt.get("amount")); + receipts.add(receipt); + } + + // The entries: everything outstanding on the days picked. + final JsonArray dateArray = command.arrayOfParameterNamed(ENTRY_DATES); + final Set dates = new LinkedHashSet<>(); + for (int i = 0; i < dateArray.size(); i++) { + dates.add(LocalDate.parse(dateArray.get(i).getAsString())); + } + final List pending = new ArrayList<>(); + for (final CollectionEntry entry : this.collectionEntryRepository.findAllPendingFor(agent.getId())) { + if (dates.contains(entry.getEntryDate())) { + pending.add(entry); + } + } + if (pending.isEmpty()) { + throw new NothingToSettleException(agent.getAgentCode(), dates.toString()); + } + + final BigDecimal expected = pending.stream().map(CollectionEntry::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add); + if (expected.compareTo(received) != 0) { + throw refused("receipt.amount.mismatch", "The vouchers come to " + received.stripTrailingZeros().toPlainString() + " but " + + agent.getAgentCode() + "'s entries for the days picked come to " + expected.stripTrailingZeros().toPlainString() + + ". They have to match exactly."); + } + + // Debited from the very account the vouchers credited, not whatever the agent names today. + final GLAccount receiptAccount = this.glAccountRepository.findById(receiptAccountId).orElse(agentAccount); + + // The application, then the money. + final LocalDate settledOn = DateUtils.getBusinessLocalDate(); + final Long applicationId = this.jdbcTemplate.queryForObject(INSERT_APPLICATION_SQL, Long.class, agent.getId(), // NOSONAR + java.sql.Date.valueOf(settledOn), expected, pending.size(), receiptAccount.getId(), + this.context.authenticatedUser().getId()); + final List voucherNos = new ArrayList<>(); + for (final Map receipt : receipts) { + // The unique constraint on the voucher is the last word: a second clerk racing this one + // fails here rather than applying the same cash twice. + this.jdbcTemplate.update(INSERT_APPLICATION_VOUCHER_SQL, applicationId, receipt.get("id"), receipt.get("voucher_no"), // NOSONAR + receipt.get("amount")); + voucherNos.add((String) receipt.get("voucher_no")); + } + final String description = "DDS collection by " + agent.getAgentCode() + ", voucher " + String.join(", ", voucherNos); + + BigDecimal pooled = BigDecimal.ZERO; + for (final CollectionEntry entry : pending) { + final Party party = collectableParty(entry.getParty().getPartyCode(), agent); + + if (party.getSavingsAccountId() != null) { + this.ddsLedger.applyAgentReceiptToSavings(party, settledOn, entry.getAmount(), receiptAccount, description); + } else { + pooled = pooled.add(entry.getAmount()); + } + + // The customer's own statement records the day the money was collected, as it always has. + // The agent's cash in hand is not raised: this cash was counted in at the counter, not + // carried. + final BigDecimal balanceAfter = party.credit(entry.getAmount()); + final PartyTransaction transaction = this.partyTransactionRepository.saveAndFlush(new PartyTransaction(party, agent, + entry.getEntryDate(), "PARTY_TRAN", "CREDIT", entry.getTransactionMode(), "GL", receiptAccount.getGlCode(), + entry.getAmount(), entry.getChequeNo(), entry.getTokenNo(), description, balanceAfter)); + + entry.markSettledByReceipt(settledOn, transaction.getId(), applicationId); + this.collectionEntryRepository.save(entry); + } + + // Everyone on DDS's own accounts, in one pair. + final String journalId = "DDSR-A" + applicationId; + if (pooled.signum() > 0) { + this.ddsLedger.applyAgentReceipts(agent.getOffice(), settledOn, pooled, receiptAccount, journalId, description); + this.jdbcTemplate.update(SET_JOURNAL_SQL, journalId, applicationId); // NOSONAR + } + + final Map changes = new LinkedHashMap<>(); + changes.put("applicationId", applicationId); + changes.put("entriesSettled", pending.size()); + changes.put("totalAmount", expected); + changes.put("settledOn", settledOn); + changes.put("vouchers", voucherNos); + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(agent.getId()).with(changes).build(); + } + + /** + * What one line collects, checked against where the account stands on the day it is written for. + * + * The bank's rules, the same ones the counter's own collection applies (DdsSchedule): + *

    + *
  • an ordinary line is today's instalment - less if the customer paid less, never more;
  • + *
  • days in advance, one to five, and the account may not end up more than five days ahead;
  • + *
  • days of arrears, one to three, and only as many as are still inside the three-day catch-up + * window - older missed days are skipped, and skipped means gone.
  • + *
+ * With days, the amount is the daily instalment times them, worked out here rather than trusted; + * one sent as well has to agree. What is already written down and still waiting at the counter is + * counted with what has been settled, so arrears cleared on one pending line cannot be cleared again + * on the next. + */ + private BigDecimal checkedAmount(final Party party, final LocalDate entryDate, final JsonObject row, final Integer advanceDays, + final Integer arrearsDays) { + final BigDecimal daily = party.getDailyDepositAmount(); + if (daily == null || daily.compareTo(BigDecimal.ZERO) <= 0) { + throw new PartyHasNoDailyAmountException(party.getPartyCode()); + } + + final int due = DdsSchedule.instalmentsDue(party.getOpeningDate(), party.getDays(), entryDate); + final BigDecimal collected = this.partyTransactionRepository.sumCollectedFor(party.getId()) + .add(this.collectionEntryRepository.sumPendingFor(party.getId())); + final DdsSchedule before = DdsSchedule.of(daily, due, collected); + + final BigDecimal amount; + if (advanceDays != null) { + amount = daily.multiply(BigDecimal.valueOf(advanceDays)); + } else if (arrearsDays != null) { + final int allowed = before.recoverableDays(daily); + if (arrearsDays > allowed) { + throw new PartyArrearsDaysExceededException(party.getPartyCode(), arrearsDays, allowed); + } + amount = daily.multiply(BigDecimal.valueOf(arrearsDays)); + } else { + amount = row.get("amount").getAsBigDecimal(); + if (amount.compareTo(daily) > 0) { + throw refused("entry.more.than.a.day", party.getPartyCode() + ": " + amount.stripTrailingZeros().toPlainString() + + " is more than one day's instalment of " + daily.stripTrailingZeros().toPlainString() + + ". Use Advance for days ahead or Previous days for days missed."); + } + } + + final Integer days = advanceDays != null ? advanceDays : arrearsDays; + if (days != null && row.has("amount") && !row.get("amount").isJsonNull() && !row.get("amount").getAsString().isBlank()) { + final BigDecimal quoted = row.get("amount").getAsBigDecimal(); + if (quoted.compareTo(amount) != 0) { + throw new PartyAdvanceAmountMismatchException(party.getPartyCode(), days, amount, quoted, advanceDays != null); + } + } + + // Whatever kind of line it is, the account may not end up more than five days ahead. + final DdsSchedule after = DdsSchedule.of(daily, due, collected.add(amount)); + if (after.advanceDays() > DdsSchedule.MAX_ADVANCE_DAYS) { + throw refused("entry.advance.exceeded", party.getPartyCode() + " would be " + after.advanceDays() + + " days ahead after this; an account may be at most " + DdsSchedule.MAX_ADVANCE_DAYS + " days ahead."); + } + return amount; + } + + private Integer integerOf(final JsonObject json, final String field) { + return json.has(field) && !json.get(field).isJsonNull() && !json.get(field).getAsString().isBlank() ? json.get(field).getAsInt() + : null; + } + + private PlatformDataIntegrityException refused(final String code, final String message) { + return new PlatformDataIntegrityException("error.msg.dds.collection." + code, message); + } + + /** + * The account this line names, if this agent may collect from it at all. + * + * The same three questions the counter asks, asked here as well - once when the agent writes the + * line down and again when the clerk settles it. Asking early is what lets an agent be told at + * the customer's door rather than hours later at the branch; asking again is what stops a line + * written this morning being posted this evening onto an account that has since been closed. + */ + /** Named rather than found empty-handed: an id that matches no agent is a caller's mistake, not a day with nothing in it. */ + private Agent agentOf(final Long agentId) { + return this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId)); + } + + private Party collectableParty(final String partyCode, final Agent agent) { + final Party party = this.partyRepository.findByPartyCode(partyCode).orElseThrow(() -> new PartyNotFoundException(partyCode)); + if (party.isClosed()) { + throw new PartyAlreadyClosedException(partyCode); + } + if (!party.getAgent().getId().equals(agent.getId())) { + throw new PartyNotCollectedByAgentException(partyCode, agent.getAgentCode()); + } + return party; + } + + private String stringOf(final JsonObject json, final String field) { + return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/starter/DdsCollectionConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/starter/DdsCollectionConfiguration.java new file mode 100644 index 000000000..c150a9a29 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/starter/DdsCollectionConfiguration.java @@ -0,0 +1,61 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.collection.starter; + +import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.agent.domain.AgentRepository; +import org.apache.fineract.portfolio.dds.accounting.DdsLedger; +import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository; +import org.apache.fineract.organisation.agent.exception.AgentNotFoundException; +import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntryRepository; +import org.apache.fineract.portfolio.dds.collection.serialization.CollectionEntryCommandFromApiJsonDeserializer; +import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryReadPlatformService; +import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryReadPlatformServiceImpl; +import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryWritePlatformService; +import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryWritePlatformServiceJpaRepositoryImpl; +import org.apache.fineract.portfolio.dds.party.domain.PartyRepository; +import org.apache.fineract.portfolio.dds.transaction.service.PartyTransactionWritePlatformService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +@Configuration +public class DdsCollectionConfiguration { + + @Bean + @ConditionalOnMissingBean(CollectionEntryReadPlatformService.class) + public CollectionEntryReadPlatformService collectionEntryReadPlatformService(JdbcTemplate jdbcTemplate, + AgentRepository agentRepository) { + return new CollectionEntryReadPlatformServiceImpl(jdbcTemplate, agentRepository); + } + + @Bean + @ConditionalOnMissingBean(CollectionEntryWritePlatformService.class) + public CollectionEntryWritePlatformService collectionEntryWritePlatformService( + CollectionEntryCommandFromApiJsonDeserializer fromApiJsonDeserializer, CollectionEntryRepository collectionEntryRepository, + AgentRepository agentRepository, PartyRepository partyRepository, + PartyTransactionWritePlatformService partyTransactionWritePlatformService, PartyTransactionRepository partyTransactionRepository, + DdsLedger ddsLedger, GLAccountRepository glAccountRepository, JdbcTemplate jdbcTemplate, PlatformSecurityContext context) { + return new CollectionEntryWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, collectionEntryRepository, + agentRepository, partyRepository, partyTransactionWritePlatformService, partyTransactionRepository, ddsLedger, + glAccountRepository, jdbcTemplate, context); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/api/PartiesApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/api/PartiesApiResource.java new file mode 100644 index 000000000..6fc39c57d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/api/PartiesApiResource.java @@ -0,0 +1,255 @@ +/** + * 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.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.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.dds.accounting.DdsLedger; +import org.apache.fineract.portfolio.dds.party.data.PartyData; +import org.apache.fineract.portfolio.dds.party.service.PartyReadPlatformService; +import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService; +import org.springframework.stereotype.Component; + +@Path("/v1/dds/parties") +@Component +@Tag(name = "DDS Parties", description = "A DDS Party is a customer's daily-deposit scheme account, linked to the Agent who collects for it. Carries the scheme's KYC, terms, two-stage overdue flags and a nominee list.") +@RequiredArgsConstructor +public class PartiesApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSIONS = "PARTY"; + + private final PlatformSecurityContext context; + private final PartyReadPlatformService readPlatformService; + private final DdsLedger ddsLedger; + private final DdsVoucherService ddsVoucherService; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + + /** + * Called directly for the one-off deposit-account catch-up. That is a data fix rather than an + * everyday action, so it takes no request body and does not need the command-source machinery - + * the account it opens and the entries it writes are their own record. + */ + private final PartyWritePlatformService partyWritePlatformService; + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve DDS Parties", description = "Returns the list of DDS parties. " + + "Pass agentId to get only the parties a given agent collects for - what the daily " + + "transaction screen needs, since it works one agent at a time.") + public Collection retrieveAll( + @QueryParam("agentId") @Parameter(description = "agentId") final Long agentId) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return readPlatformService.retrieveAll(agentId); + } + + @GET + @Path("dueforclosure") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "DDS accounts due for closure", description = "The accounts whose term has run out and which are still " + + "open - the branch's list of closures waiting to be done, oldest maturity first.\n" + "\n" + + "Nothing closes itself. A matured account is money the bank now owes the customer, and where it goes has to be " + + "agreed with them, so it stays on this list until a clerk deals with it - in two steps, paying it out and then " + + "closing it. An account still carrying a balance is waiting on the payment; one showing nil has been paid and " + + "is waiting on the close.") + public Collection retrieveDueForClosure() { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return readPlatformService.retrieveDueForClosure(); + } + + @GET + @Path("creditaccounts") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Accounts a DDS maturity may be paid into", + description = "The customer's own accounts that can take a maturity payout: active, a savings account rather " + + "than a fixed or recurring deposit, and not one of their DDS scheme accounts.\n" + "\n" + + "The same conditions command=payout enforces, so a screen offering this list cannot offer a choice " + + "the server will then refuse. Returns id, accountNo, productName and balance.") + public Collection> retrieveCreditableAccounts( + @QueryParam("clientId") @Parameter(description = "clientId") final Long clientId) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return readPlatformService.retrieveCreditableAccounts(clientId); + } + + @GET + @Path("sundryaccounts") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Sundry accounts a DDS balance may be parked in", + description = "For a customer with no account here: every open posting liability account that is not a customer " + + "or agent control account, by GL code, for the clerk to search and pick their bank's Sundry. The same " + + "list command=payout and command=closeEarly accept as sundryGlAccountId (required when parking). " + + "Returns id, glCode and name.") + public List> retrieveSundryAccounts() { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return ddsLedger.sundryAccounts(); + } + + @GET + @Path("{partyId}/closure") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "How a closed DDS account was closed", + description = "For a closed account: the kind of closure (PREMATURE, MATURITY_PAYOUT_AND_CLOSE, MATURITY_AUTO_CLOSE, " + + "MATURITY_PAYOUT_THEN_CLOSE, CLOSED_NIL_BALANCE), who closed and approved it, the lock-in reason, the balance, " + + "interest recovered or topped up, the agent's penalty, what was paid out and where it went - the customer's " + + "account or the Sundry GL - with both ledger legs. For an open account returns closed=false.") + public Map retrieveClosure(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return readPlatformService.retrieveClosure(partyId); + } + + @GET + @Path("{partyId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve a DDS Party", description = "Returns the details of a DDS party, including its nominees.") + public PartyData retrieveOne(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return readPlatformService.retrieveOne(partyId); + } + + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Create a DDS Party", description = "Mandatory fields: clientId, agentId, subType, partyName, address1, " + + "city, pincode, mobileNoSms, operation, adultMinor, openingDate, dailyDepositAmount, days\n" + "\n" + + "Optional fields: lastAccount, nominal, aadharNo, partyName1, address2, address3, gender, panCardNo, telephone, " + + "birthDate, occupation, risk, ckycId, minorDate, guardianClientId, minAccountOpt, relation, month, " + + "reopenDate, depositAmount, ledgerNo, withdrawType, lien, payDate, paymentAmount, openingBalance, isSmsActive, " + + "isEmailActive, interestDate, spDescription, freezeCode, advCode, creditToBranch, creditToAccountType, " + + "creditToAccount, nomineeApplicable, nomineeSplitMode, nominees, savingsAccountNo (links this party to an existing, " + + "non-closed real savings account of the same client, resolved by account number - omit if the client has none)\n" + "\n" + + "maturityDate and maturityAmount are not accepted: both are worked out from openingDate, month/days, " + + "dailyDepositAmount and interestRate, and are returned on read.") + public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) { + final CommandWrapper commandRequest = new CommandWrapperBuilder() // + .createParty() // + .withJson(apiRequestBodyAsJson) // + .build(); + return commandsSourceWritePlatformService.logCommandSource(commandRequest); + } + + @PUT + @Path("{partyId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Update a DDS Party", description = "Updates the details of a DDS party.") + public CommandProcessingResult update(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + final CommandWrapper commandRequest = new CommandWrapperBuilder() // + .updateParty(partyId) // + .withJson(apiRequestBodyAsJson) // + .build(); + return commandsSourceWritePlatformService.logCommandSource(commandRequest); + } + + @POST + @Path("{partyId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Act on a DDS Party", description = "command=close closes the party - its balance must already be " + + "zero (settled with command=payout, or with a full Withdraw Deposite) and closeDate is mandatory.\n" + "\n" + + "command=opendepositaccount opens the real deposit account of a party created before DDS began opening them, " + + "and brings its existing balance onto that account. Run once per such party; refused for a party that already " + + "has an account. No request body.\n" + "\n" + + "command=payout pays a matured account out and leaves it open with a nil balance, for command=close to be run " + + "against separately once the clerk has seen the money land. What is paid is what the account actually holds - " + + "the deposits plus the interest already posted - not the figure quoted at opening. Optional field: " + + "toSavingsAccountNo, another open savings account of the same customer, which the money is moved into. Omit it " + + "when the customer has no other account here: the money is then parked in the matured-deposits payable account " + + "for the branch to send on to their account at another bank. Refused before the maturity date, and refused on " + + "an account with nothing left to pay - that one needs closing, not paying.\n" + "\n" + + "command=prematureclose closes an account before its term is up and pays out what is left, in one act - " + + "unlike payout, which leaves the account open. Mandatory field: shortfallBorneBy, either CUSTOMER (their " + + "interest is recalculated at the scheme's reduced rate and the difference taken back) or AGENT (the customer " + + "keeps a full month's interest and the agent funds the days left in it). The agent is charged a share of " + + "their commission either way. Optional field: toSavingsAccountNo, as for payout. The lock-in period, the " + + "rate reduction and the agent's share are the scheme's own and are read as the account is closed, so terms " + + "the bank changes take effect on accounts already open. Refused inside the lock-in period, and refused on an " + + "account that has already matured - that one is paid out and closed in the ordinary way.\n" + "\n" + + "command=prematurecloseoverride is the same early closure for one account still inside its lock-in. It takes " + + "the same fields plus mandatory lockinOverrideReason, runs under its own permission so it goes to a manager " + + "for approval, and leaves the scheme's lock-in unchanged for every other account.") + public CommandProcessingResult handleCommand(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId, + @QueryParam("command") @Parameter(description = "command") final String commandParam, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + if ("opendepositaccount".equals(commandParam)) { + return this.partyWritePlatformService.openDepositAccount(partyId); + } + if ("payout".equals(commandParam)) { + final CommandWrapper commandRequest = new CommandWrapperBuilder() // + .payOutParty(partyId) // + .withJson(apiRequestBodyAsJson) // + .build(); + return ddsVoucherService.submitHoldingVoucher(DdsVoucherService.TYPE_MATURITY_PAYOUT, DdsVoucherService.REF_PARTY, + partyId, () -> ddsVoucherService.amountHeldBy(partyId), + () -> commandsSourceWritePlatformService.logCommandSource(commandRequest)); + } + if ("prematureclose".equals(commandParam)) { + final CommandWrapper commandRequest = new CommandWrapperBuilder() // + .prematureCloseParty(partyId) // + .withJson(apiRequestBodyAsJson) // + .build(); + return ddsVoucherService.submitHoldingVoucher(DdsVoucherService.TYPE_PREMATURE, DdsVoucherService.REF_PARTY, + partyId, () -> ddsVoucherService.amountHeldBy(partyId), + () -> commandsSourceWritePlatformService.logCommandSource(commandRequest)); + } + if ("prematurecloseoverride".equals(commandParam)) { + final CommandWrapper commandRequest = new CommandWrapperBuilder() // + .prematureCloseOverrideParty(partyId) // + .withJson(apiRequestBodyAsJson) // + .build(); + return ddsVoucherService.submitHoldingVoucher(DdsVoucherService.TYPE_PREMATURE, DdsVoucherService.REF_PARTY, + partyId, () -> ddsVoucherService.amountHeldBy(partyId), + () -> commandsSourceWritePlatformService.logCommandSource(commandRequest)); + } + if (!"close".equals(commandParam)) { + throw new UnrecognizedQueryParamException("command", commandParam); + } + final CommandWrapper commandRequest = new CommandWrapperBuilder() // + .closeParty(partyId) // + .withJson(apiRequestBodyAsJson) // + .build(); + return commandsSourceWritePlatformService.logCommandSource(commandRequest); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/domain/PartyRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/domain/PartyRepository.java new file mode 100644 index 000000000..b8d37f63a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/domain/PartyRepository.java @@ -0,0 +1,134 @@ +/** + * 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 java.time.LocalDate; +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 PartyRepository extends JpaRepository { + + /** + * Native rather than JPQL: {@code cast(... as long)} reaches PostgreSQL verbatim, which knows no + * "long" type - the cast has to name a database type. + */ + @Query(value = "select coalesce(max(cast(substring(party_code, 3) as bigint)), 0) from m_dds_party " + + "where party_code like 'PT%'", nativeQuery = true) + Long retrieveMaxPartyCodeSuffix(); + + Optional findByPartyCode(String partyCode); + + /** + * Open accounts still assigned to this agent. Nobody collects for an agent who has been switched + * off, so these would simply stop being collected from without anyone being told. + */ + @Query("select count(p) from Party p where p.agent.id = :agentId and p.status = 'ACTIVE'") + Long countActivePartiesOfAgent(@Param("agentId") Long agentId); + + /** Open accounts on a scheme, which take its rate when the rate is changed. */ + @Query("select p from Party p where p.subType = :subType and p.status = 'ACTIVE'") + List findActiveOnSubType(@Param("subType") String subType); + + /** + * Accounts an interest run covers. Closed accounts are excluded: a closed account has been paid + * out and settled to zero, so accruing further interest on it would credit an account nobody can + * draw on and put the DDS balances out of step with what was actually paid. + */ + @Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo " + + "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType)") + List findForInterestBatch(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo, + @Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType); + + @Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo " + + "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) " + + "and (p.od1Flag = true or p.od2Flag = true)") + List findForOdInterestBatch(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo, + @Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType); + + /** + * Accounts holding interest that has been calculated but not yet credited - what an Interest + * Posting run has to pay in. Accounts holding nothing are left out rather than previewed as zero + * rows. + */ + @Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo " + + "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) and p.interestCalc > 0") + List findWithAccruedInterest(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo, + @Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType); + + @Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo " + + "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) and p.odInterestCalc > 0") + List findWithAccruedOdInterest(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo, + @Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType); + + /** + * Matured accounts a Maturity Auto Close run could settle: the term is up, there is money to hand + * back, and the customer has already said which account to hand it into. + * + * The destination is part of the search rather than a check made afterwards, because it is what + * separates an account this run may touch from one it may not. Without it there is nowhere to + * send the money, and the run's whole premise - that nobody need choose anything today, because + * the choice was made when the account was opened - does not hold. + * + * Two fields carry that choice and both are asked for. An account number on its own is a note of + * where the customer banks; the Adv Code is the instruction to use it, and only "Transfer To + * Saving" says the matured amount goes there. Reading the number without the code would settle + * accounts whose customer never asked for it - fourteen of the fifteen on the books say "None". + * + * Whether the scheme has auto-closing switched on is decided by the caller, which holds the + * schemes; this asks only about the accounts. Ordered oldest maturity first, so the ones that + * have waited longest are listed at the top of what the clerk reviews. + */ + @Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo " + + "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) " + + "and p.maturityDate is not null and p.maturityDate <= :asOn and p.currentBalance > 0 " + + "and lower(p.advCode) = 'transfer to saving' " + + "and p.creditToAccount is not null and length(trim(p.creditToAccount)) > 0 " + + "order by p.maturityDate, p.id") + List findDueForMaturityAutoClose(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo, + @Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType, + @Param("asOn") LocalDate asOn); + + /** + * Matured accounts whose interest has not been brought up to their maturity date. + * + * Interest reaches an account through a posting run, and a run is a monthly thing, set per + * scheme and switchable off. A term ending between two runs therefore leaves the last stretch + * uncredited, and a payout made before the next run hands the customer less than they earned - + * which is what happened to every account on a scheme with automatic posting turned off. + * + * Maturity is the one date the money cannot wait for the next run, so these are found on their + * own and brought up to date. Anything already posted through its maturity date is left alone. + */ + @Query("select p from Party p where p.status = 'ACTIVE' and p.maturityDate is not null and p.maturityDate <= :asOn " + + "and (p.interestDate is null or p.interestDate < p.maturityDate) order by p.maturityDate, p.id") + List findMaturedWithInterestOutstanding(@Param("asOn") LocalDate asOn); + + /** + * Minors' accounts whose holder has reached the age of majority. + * + * Found on their birthday and not before, but turned over only once their own KYC is verified - + * the caller checks that, because the answer lives on the bank's KYC record rather than here. + */ + @Query("select p from Party p where p.status = 'ACTIVE' and lower(p.adultMinor) = 'minor' " + + "and p.birthDate is not null and p.birthDate <= :bornOnOrBefore order by p.id") + List findMinorsWhoHaveComeOfAge(@Param("bornOnOrBefore") LocalDate bornOnOrBefore); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/DdsDepositProductNotConfiguredException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/DdsDepositProductNotConfiguredException.java new file mode 100644 index 000000000..231c25951 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/DdsDepositProductNotConfiguredException.java @@ -0,0 +1,37 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.party.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +/** + * Thrown when a DDS party cannot be opened because no savings product has been named for DDS. + * + * Refusing is the point. A party created without a real account behind it is the state this whole + * change exists to end - the customer's money would live only inside DDS again, and nobody would + * notice until someone asked the bank's books where it was. + */ +public class DdsDepositProductNotConfiguredException extends AbstractPlatformDomainRuleException { + + public DdsDepositProductNotConfiguredException() { + super("error.msg.dds.deposit.product.not.configured", + "No savings product is set for DDS accounts, so this customer's account cannot be opened. " + + "Set the 'dds-deposit-product' configuration to a savings product first."); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyAccountAlreadyOpenException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyAccountAlreadyOpenException.java new file mode 100644 index 000000000..e7c56dedb --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyAccountAlreadyOpenException.java @@ -0,0 +1,36 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.party.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +/** + * Thrown when a party that already has a deposit account is asked to open one. + * + * Two accounts for one customer is worse than the one they have: collections would start splitting + * between them, and the opening balance would be counted twice. The catch-up is meant to be run once + * per party, and running it twice is the mistake this catches. + */ +public class PartyAccountAlreadyOpenException extends AbstractPlatformDomainRuleException { + + public PartyAccountAlreadyOpenException(final String partyCode) { + super("error.msg.dds.party.account.already.open", + "Party " + partyCode + " already has a deposit account, so another one cannot be opened for it.", partyCode); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyAlreadyMaturedException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyAlreadyMaturedException.java new file mode 100644 index 000000000..ed4d90f46 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyAlreadyMaturedException.java @@ -0,0 +1,40 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.party.exception; + +import java.time.LocalDate; +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +/** + * Thrown when an account that has already reached its term is put through an early closure. + * + * Nothing was closed early, so there is no shortfall to settle: the customer saved for the whole term + * and has earned the interest in full. Charging for an early closure here would take money they are + * owed, which is why this is refused rather than quietly charging nothing - the clerk has picked the + * wrong action, and should pay the account out and close it in the ordinary way. + */ +public class PartyAlreadyMaturedException extends AbstractPlatformDomainRuleException { + + public PartyAlreadyMaturedException(final String partyCode, final LocalDate maturityDate) { + super("error.msg.dds.party.already.matured", + "Account " + partyCode + " matured on " + maturityDate + + ", so it cannot be closed early. Pay it out and close it in the ordinary way.", + partyCode, maturityDate); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyCreditAccountUnusableException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyCreditAccountUnusableException.java new file mode 100644 index 000000000..84662dabd --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyCreditAccountUnusableException.java @@ -0,0 +1,36 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.party.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +/** + * Thrown when a party's maturity proceeds are pointed at an account that cannot receive them. + * + * The whole matured amount goes to this account, years after somebody typed it. Refusing it now puts + * the problem in front of the officer who can still ask the customer; the alternative is finding out + * on the maturity date, with the money owed and nowhere to send it. + */ +public class PartyCreditAccountUnusableException extends AbstractPlatformDomainRuleException { + + public PartyCreditAccountUnusableException(final String accountNo, final String reason) { + super("error.msg.dds.party.credit.account.unusable", "The maturity proceeds cannot be credited to account " + accountNo + ": " + + reason + ". Pick one of this customer's own open savings accounts.", accountNo, reason); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyHasNoDepositAccountException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyHasNoDepositAccountException.java new file mode 100644 index 000000000..8c5b93c07 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyHasNoDepositAccountException.java @@ -0,0 +1,36 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.party.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +/** + * Thrown when a maturity payout is attempted on a party that has no real deposit account behind it. + * + * There is nowhere to pay the money out of. Parties opened before DDS began opening real accounts + * are in this state; the deposit-account catch-up brings them onto the books, and only then can they + * be paid out and closed. + */ +public class PartyHasNoDepositAccountException extends AbstractPlatformDomainRuleException { + + public PartyHasNoDepositAccountException(final String partyCode) { + super("error.msg.dds.party.has.no.deposit.account", "Account " + partyCode + + " has no deposit account behind it, so there is nothing to pay out from. Open its deposit account first.", partyCode); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyHeldUnderLienException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyHeldUnderLienException.java new file mode 100644 index 000000000..f751bfe25 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyHeldUnderLienException.java @@ -0,0 +1,71 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.party.exception; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +/** + * Thrown when a matured account is put through a payout while money on it is pledged against a loan. + * + * A customer who needs cash before their term ends can borrow against the deposit instead of breaking + * it: the scheme carries on, and the amount lent is marked as a lien so it cannot leave. Paying the + * deposit out while that stands would hand the customer money the bank is holding as its security and + * leave the loan with nothing behind it. + * + * So maturity waits on the loan. The term has ended and the account is owed, but it is not paid until + * the borrowing against it is cleared and the lien released. + */ +public class PartyHeldUnderLienException extends AbstractPlatformDomainRuleException { + + public PartyHeldUnderLienException(final String partyCode, final BigDecimal heldAmount) { + super("error.msg.dds.party.held.under.lien", + "Account " + partyCode + " has " + inRupees(heldAmount) + " held under lien against a loan. " + + "The loan must be repaid and the lien released before this deposit can be paid out.", + partyCode, heldAmount); + } + + /** + * The whole account is held, by the lien marked on the party itself. + * + * No amount, because there is none to state: DDS pays a deposit out whole or not at all, so a + * lien on part of one would hold nothing the customer could otherwise have reached. The reason + * takes its place, and is what the clerk needs - "held" they can already see, "held for what" + * is the thing nobody could answer before. + */ + public PartyHeldUnderLienException(final String partyCode, final String reason) { + super("error.msg.dds.party.held.under.lien", + "Account " + partyCode + " is under lien" + because(reason) + + ". The lien must be released before money can leave this account.", + partyCode, reason); + } + + private static String because(final String reason) { + return reason == null || reason.isBlank() ? "" : " (" + reason.trim() + ")"; + } + + /** + * Balances are carried to six places, which is right for arithmetic and wrong for a sentence - a + * clerk reading "500.000000" sees a system message, not an amount of money. + */ + private static String inRupees(final BigDecimal amount) { + return amount.setScale(2, RoundingMode.HALF_UP).toPlainString(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyMajorWithoutKycException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyMajorWithoutKycException.java new file mode 100644 index 000000000..ce887c0fc --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyMajorWithoutKycException.java @@ -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.portfolio.dds.party.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +/** + * Thrown when money is to leave a minor's account after they have come of age, and their own KYC has + * not been verified. + * + * Until eighteen the guardian operates the account and the bank deals with them. After it the + * customer is the account holder in their own right, and the guardian's authority ends - so the bank + * has nobody it may pay until it has verified who the customer now is. + * + * Only the way out is closed. Collections carry on, because money arriving carries no such risk and + * a customer's saving should not stop over paperwork that is not theirs to chase. + */ +public class PartyMajorWithoutKycException extends AbstractPlatformDomainRuleException { + + public PartyMajorWithoutKycException(final String partyCode) { + super("error.msg.dds.party.major.kyc.not.verified", + "Account " + partyCode + " has come of age and its holder's KYC is not yet verified. " + + "Complete KYC before paying out - collections may continue meanwhile.", + partyCode); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyNotMaturedException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyNotMaturedException.java new file mode 100644 index 000000000..1acd7077b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyNotMaturedException.java @@ -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.party.exception; + +import java.time.LocalDate; +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +/** + * Thrown when an account whose term has not run out is put through a maturity payout. + * + * Taking the money out early is a premature withdrawal, and that is a different thing: it carries a + * penalty and its own interest terms, agreed with the customer. Letting it through here would pay a + * customer the full matured value on a term they have not completed. + */ +public class PartyNotMaturedException extends AbstractPlatformDomainRuleException { + + public PartyNotMaturedException(final String partyCode, final LocalDate maturityDate) { + super("error.msg.dds.party.not.matured", + maturityDate == null + ? "Account " + partyCode + " has no maturity date, so it cannot be paid out as a matured account." + : "Account " + partyCode + " matures on " + maturityDate + + ", so it cannot be paid out yet. Taking it out before then is a premature withdrawal.", + partyCode, maturityDate); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyNothingToPayOutException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyNothingToPayOutException.java new file mode 100644 index 000000000..6313a4c5c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyNothingToPayOutException.java @@ -0,0 +1,37 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.party.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +/** + * Thrown when an account with nothing in it is put through a payout. + * + * It has already been paid, or nothing was ever collected on it. Either way there is no payment to + * make - what it needs now is closing, which is the separate action. Letting this through would + * write a zero-value transaction and a pair of zero journal entries that mean nothing to anybody + * reading the account later. + */ +public class PartyNothingToPayOutException extends AbstractPlatformDomainRuleException { + + public PartyNothingToPayOutException(final String partyCode) { + super("error.msg.dds.party.nothing.to.pay.out", + "Account " + partyCode + " has nothing left to pay out. It can be closed instead.", partyCode); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyWithinLockinPeriodException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyWithinLockinPeriodException.java new file mode 100644 index 000000000..142328302 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/exception/PartyWithinLockinPeriodException.java @@ -0,0 +1,44 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.party.exception; + +import java.time.LocalDate; +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +/** + * Thrown when an account is put through an early closure before its scheme's lock-in period is up. + * + * Below the lock-in the bank does not want the account broken on any terms, so this is a refusal + * rather than a charge: there is no reduced rate or agent penalty that would make it allowed. The + * customer either waits until the date named here, or borrows against the deposit and leaves it + * standing. + * + * The period is the scheme's own, not one figure for the whole bank - a two-month scheme and a + * five-year one cannot share a lock-in without one of them becoming impossible to close early. + */ +public class PartyWithinLockinPeriodException extends AbstractPlatformDomainRuleException { + + public PartyWithinLockinPeriodException(final String partyCode, final int lockinMonths, final LocalDate openOn, + final LocalDate closableFrom) { + super("error.msg.dds.party.within.lockin.period", + "Account " + partyCode + " was opened on " + openOn + " and its scheme has a lock-in of " + lockinMonths + + " months, so it cannot be closed early before " + closableFrom + ".", + partyCode, lockinMonths, openOn, closableFrom); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/handler/ClosePartyCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/handler/ClosePartyCommandHandler.java new file mode 100644 index 000000000..6cec96a61 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/handler/ClosePartyCommandHandler.java @@ -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.party.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.party.service.PartyWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "PARTY", action = "CLOSE") +public class ClosePartyCommandHandler implements NewCommandSourceHandler { + + private final PartyWritePlatformService writePlatformService; + + @Autowired + public ClosePartyCommandHandler(final PartyWritePlatformService writePlatformService) { + this.writePlatformService = writePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.closeParty(command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/handler/CreatePartyCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/handler/CreatePartyCommandHandler.java new file mode 100644 index 000000000..43b292e20 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/handler/CreatePartyCommandHandler.java @@ -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.party.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.party.service.PartyWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "PARTY", action = "CREATE") +public class CreatePartyCommandHandler implements NewCommandSourceHandler { + + private final PartyWritePlatformService writePlatformService; + + @Autowired + public CreatePartyCommandHandler(final PartyWritePlatformService writePlatformService) { + this.writePlatformService = writePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.createParty(command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/handler/PayOutPartyCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/handler/PayOutPartyCommandHandler.java new file mode 100644 index 000000000..522556b2c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/handler/PayOutPartyCommandHandler.java @@ -0,0 +1,52 @@ +/** + * 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.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.party.service.PartyWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Pays a matured DDS account out, leaving it open with a nil balance for the clerk to close. + * + * Its own command rather than part of CLOSE, because this one moves money - so it earns its own + * permission, its own audit row, and its own place in a maker-checker queue if the bank wants one. + */ +@Service +@CommandType(entity = "PARTY", action = "PAYOUT") +public class PayOutPartyCommandHandler implements NewCommandSourceHandler { + + private final PartyWritePlatformService writePlatformService; + + @Autowired + public PayOutPartyCommandHandler(final PartyWritePlatformService writePlatformService) { + this.writePlatformService = writePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.payOut(command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/handler/PrematureCloseOverridePartyCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/handler/PrematureCloseOverridePartyCommandHandler.java new file mode 100644 index 000000000..ecde8bb16 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/handler/PrematureCloseOverridePartyCommandHandler.java @@ -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.party.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.party.service.PartyWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Closes one DDS account early inside its scheme's lock-in. + * + * Its own command rather than a flag on PREMATURECLOSE, so waiving a lock-in has its own permission and + * its own maker-checker queue: the clerk asks, with a reason, and a manager decides. The scheme's + * lock-in is never touched to let one account through. + */ +@Service +@CommandType(entity = "PARTY", action = "PREMATURECLOSEOVERRIDE") +public class PrematureCloseOverridePartyCommandHandler implements NewCommandSourceHandler { + + private final PartyWritePlatformService writePlatformService; + + @Autowired + public PrematureCloseOverridePartyCommandHandler(final PartyWritePlatformService writePlatformService) { + this.writePlatformService = writePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.prematureCloseWithinLockin(command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/handler/PrematureClosePartyCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/handler/PrematureClosePartyCommandHandler.java new file mode 100644 index 000000000..3a28f9139 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/handler/PrematureClosePartyCommandHandler.java @@ -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.party.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.party.service.PartyWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Closes a DDS account before its term is up. + * + * Its own command rather than a flag on PAYOUT, because it is the one action here that changes what + * the customer is owed - it settles the shortfall against either their interest or the agent's + * commission. That earns it its own permission, its own audit row, and its own maker-checker queue. + */ +@Service +@CommandType(entity = "PARTY", action = "PREMATURECLOSE") +public class PrematureClosePartyCommandHandler implements NewCommandSourceHandler { + + private final PartyWritePlatformService writePlatformService; + + @Autowired + public PrematureClosePartyCommandHandler(final PartyWritePlatformService writePlatformService) { + this.writePlatformService = writePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.prematureClose(command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/handler/UpdatePartyCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/handler/UpdatePartyCommandHandler.java new file mode 100644 index 000000000..37c3c99e5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/handler/UpdatePartyCommandHandler.java @@ -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.party.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.party.service.PartyWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "PARTY", action = "UPDATE") +public class UpdatePartyCommandHandler implements NewCommandSourceHandler { + + private final PartyWritePlatformService writePlatformService; + + @Autowired + public UpdatePartyCommandHandler(final PartyWritePlatformService writePlatformService) { + this.writePlatformService = writePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.updateParty(command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/serialization/PartyCommandFromApiJsonDeserializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/serialization/PartyCommandFromApiJsonDeserializer.java new file mode 100644 index 000000000..148a98f7f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/serialization/PartyCommandFromApiJsonDeserializer.java @@ -0,0 +1,645 @@ +/** + * 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.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.Locale; +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.service.DateUtils; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.portfolio.dds.party.domain.PartyNominee; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public final class PartyCommandFromApiJsonDeserializer { + + private static final Set SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("clientId", "agentId", "subType", + "lastAccount", "nominal", "aadharNo", "partyName", "partyName1", "address1", "address2", "address3", "city", "pincode", + "gender", "panCardNo", "telephone", "mobileNoSms", "operation", "birthDate", "occupation", "risk", "ckycId", "adultMinor", + // maturityDate and maturityAmount are deliberately absent: the Party works both out from + // the opening date and the terms, so a caller supplying them is refused rather than + // silently ignored. + "minorDate", "guardianClientId", "minAccountOpt", "relation", "openingDate", "month", "reopenDate", + "depositAmount", "dailyDepositAmount", "days", "ledgerNo", "interestRate", "withdrawType", "lien", "payDate", + "paymentAmount", "openingBalance", "isSmsActive", "isEmailActive", "interestDate", "spDescription", "freezeCode", "advCode", + "creditToBranch", "creditToAccountType", "creditToAccount", "nomineeApplicable", "nomineeSplitMode", "nominees", + "savingsAccountNo", "officeId", "lienReason", "dateFormat", "locale")); + + private static final Set SUPPORTED_NOMINEE_PARAMETERS = new HashSet<>( + Arrays.asList("nominationDate", "name", "mobileNo", "birthDate", "minor", "relation", "gender", "guardianName", + "sharePercent", "sequenceNo")); + + /** + * The array the nominees sit in. Named on every error raised against one, because the validator + * builds "nominees[0][name]" from the array name and the field name together - a field named + * without its array reads back as "null[0][name]". + */ + private static final String NOMINEES = "nominees"; + + /** The split that states a share per nominee; the other, Sequence, states a place in the order. */ + private static final String PERCENTAGE_SPLIT = "Percentage"; + + private static final String SEQUENCE_SPLIT = "Sequence"; + + /** What those shares have to come to between them - the whole deposit, not a part of it. */ + private static final BigDecimal HUNDRED_PERCENT = BigDecimal.valueOf(100); + + /* + * What each identifier has to look like. + * + * Length alone was all these carried, so a mobile number could be typed as letters and a PAN as + * anything ten characters long. None of it shows until somebody tries to use it - an SMS that + * never arrives, a PAN the tax portal rejects - by which time the customer is not in the branch + * any more. + * + * Aadhaar and PAN follow the patterns CBS already applies on the Client Family Member screen, + * rather than a second opinion of the same thing. + */ + private static final Pattern MOBILE_PATTERN = Pattern.compile("^[6-9][0-9]{9}$"); + + /** Landlines vary; what they are not is letters. STD codes and separators are allowed through. */ + 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}$"); + + /** + * Twelve digits, or the masked form CKYC hands back - the last four digits with the rest as Xs. + * A record taken from CKYC arrives masked by design, and refusing it would refuse the bank's own + * verified answer. + */ + private static final Pattern AADHAAR_PATTERN = Pattern.compile("^([0-9]{12}|[Xx]{8}[0-9]{4})$"); + + private static final Pattern PAN_PATTERN = Pattern.compile("^[A-Z]{5}[0-9]{4}[A-Z]$"); + + private static final Pattern CKYC_PATTERN = Pattern.compile("^[0-9]{14}$"); + + private final FromJsonHelper fromApiJsonHelper; + + @Autowired + public PartyCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) { + this.fromApiJsonHelper = fromApiJsonHelper; + } + + public void validateForCreate(final String json) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + + final Type typeOfMap = new TypeToken>() { + + }.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("party"); + + final JsonElement element = this.fromApiJsonHelper.parse(json); + + v.reset().parameter("clientId").value(this.fromApiJsonHelper.extractLongNamed("clientId", element)).notNull() + .integerGreaterThanZero(); + v.reset().parameter("agentId").value(this.fromApiJsonHelper.extractLongNamed("agentId", element)).notNull() + .integerGreaterThanZero(); + // The branch the account belongs to, asked for rather than taken from the collecting agent: + // an agent of one branch may hold accounts for customers of another, and an account that + // silently inherits its agent's branch is counted at the wrong one. + v.reset().parameter("officeId").value(this.fromApiJsonHelper.extractLongNamed("officeId", element)).notNull() + .integerGreaterThanZero(); + v.reset().parameter("subType").value(this.fromApiJsonHelper.extractStringNamed("subType", element)).notBlank() + .notExceedingLengthOf(60); + v.reset().parameter("partyName").value(this.fromApiJsonHelper.extractStringNamed("partyName", element)).notBlank() + .notExceedingLengthOf(100); + v.reset().parameter("address1").value(this.fromApiJsonHelper.extractStringNamed("address1", element)).notBlank() + .notExceedingLengthOf(200); + v.reset().parameter("city").value(this.fromApiJsonHelper.extractStringNamed("city", element)).notBlank() + .notExceedingLengthOf(100); + v.reset().parameter("pincode").value(this.fromApiJsonHelper.extractStringNamed("pincode", element)).notBlank() + .notExceedingLengthOf(20); + v.reset().parameter("mobileNoSms").value(this.fromApiJsonHelper.extractStringNamed("mobileNoSms", element)).notBlank() + .notExceedingLengthOf(20); + v.reset().parameter("operation").value(this.fromApiJsonHelper.extractStringNamed("operation", element)).notBlank() + .notExceedingLengthOf(20); + v.reset().parameter("adultMinor").value(this.fromApiJsonHelper.extractStringNamed("adultMinor", element)).notBlank() + .notExceedingLengthOf(20); + validateGuardianForMinor(element, v); + // An account opens the day it is opened. A date in the past would make the scheme look older + // than it is - its lock-in would appear to be further along, and its maturity would fall + // earlier than the money was actually collected for. Checked only here, on creation: an + // existing account's opening date is in the past by definition, and applying this on update + // would make every account already on the books uneditable. + v.reset().parameter("openingDate").value(this.fromApiJsonHelper.extractLocalDateNamed("openingDate", element)).notNull() + .validateDateAfterOrEqual(DateUtils.getBusinessLocalDate()); + v.reset().parameter("dailyDepositAmount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("dailyDepositAmount", element)) + .notNull().positiveAmount(); + validateTermLength(element, v); + + validateOptionalFields(element, v); + validateIdentifierFormats(element, v); + validateLien(element, v); + + if (this.fromApiJsonHelper.parameterExists("nominees", element)) { + validateNominees(element.getAsJsonObject(), dataValidationErrors); + } + + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + + public void validateForUpdate(final String json) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + + final Type typeOfMap = new TypeToken>() { + + }.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("party"); + + final JsonElement element = this.fromApiJsonHelper.parse(json); + + if (this.fromApiJsonHelper.parameterExists("clientId", element)) { + v.reset().parameter("clientId").value(this.fromApiJsonHelper.extractLongNamed("clientId", element)).notNull() + .integerGreaterThanZero(); + } + if (this.fromApiJsonHelper.parameterExists("agentId", element)) { + v.reset().parameter("agentId").value(this.fromApiJsonHelper.extractLongNamed("agentId", element)).notNull() + .integerGreaterThanZero(); + } + if (this.fromApiJsonHelper.parameterExists("subType", element)) { + v.reset().parameter("subType").value(this.fromApiJsonHelper.extractStringNamed("subType", element)).notBlank() + .notExceedingLengthOf(60); + } + if (this.fromApiJsonHelper.parameterExists("partyName", element)) { + v.reset().parameter("partyName").value(this.fromApiJsonHelper.extractStringNamed("partyName", element)).notBlank() + .notExceedingLengthOf(100); + } + if (this.fromApiJsonHelper.parameterExists("address1", element)) { + v.reset().parameter("address1").value(this.fromApiJsonHelper.extractStringNamed("address1", element)).notBlank() + .notExceedingLengthOf(200); + } + if (this.fromApiJsonHelper.parameterExists("city", element)) { + v.reset().parameter("city").value(this.fromApiJsonHelper.extractStringNamed("city", element)).notBlank() + .notExceedingLengthOf(100); + } + if (this.fromApiJsonHelper.parameterExists("pincode", element)) { + v.reset().parameter("pincode").value(this.fromApiJsonHelper.extractStringNamed("pincode", element)).notBlank() + .notExceedingLengthOf(20); + } + if (this.fromApiJsonHelper.parameterExists("mobileNoSms", element)) { + v.reset().parameter("mobileNoSms").value(this.fromApiJsonHelper.extractStringNamed("mobileNoSms", element)).notBlank() + .notExceedingLengthOf(20); + } + if (this.fromApiJsonHelper.parameterExists("operation", element)) { + v.reset().parameter("operation").value(this.fromApiJsonHelper.extractStringNamed("operation", element)).notBlank() + .notExceedingLengthOf(20); + } + if (this.fromApiJsonHelper.parameterExists("adultMinor", element)) { + v.reset().parameter("adultMinor").value(this.fromApiJsonHelper.extractStringNamed("adultMinor", element)).notBlank() + .notExceedingLengthOf(20); + } + if (this.fromApiJsonHelper.parameterExists("openingDate", element)) { + v.reset().parameter("openingDate").value(this.fromApiJsonHelper.extractLocalDateNamed("openingDate", element)).notNull(); + } + if (this.fromApiJsonHelper.parameterExists("dailyDepositAmount", element)) { + v.reset().parameter("dailyDepositAmount") + .value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("dailyDepositAmount", element)).notNull() + .positiveAmount(); + } + if (this.fromApiJsonHelper.parameterExists("days", element)) { + v.reset().parameter("days").value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("days", element)).notNull() + .integerGreaterThanZero(); + } + + validateOptionalFields(element, v); + validateIdentifierFormats(element, v); + validateLien(element, v); + + if (this.fromApiJsonHelper.parameterExists("nominees", element)) { + validateNominees(element.getAsJsonObject(), dataValidationErrors); + } + + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + + /** + * A maturity payout takes at most one field: another savings account of the customer's to move + * the money into. Omitted means they have none here, and it is parked for transfer to their + * account at another bank. + * + * The amount is deliberately not accepted. It is whatever the account holds - a caller naming a + * figure could pay out more than the customer has, or quietly leave some behind. + */ + /** + * An early closure has to say who carries the cost - there is no sensible default, because the + * two answers take money from different people. Everything else about the terms comes from the + * scheme, so this is the only choice the caller gets to make. + */ + /** + * An early closure. One waiving the lock-in carries the reason for it as well - the one thing the + * approving manager has to go on, and what the passbook line records. + */ + public void validateForPrematureClose(final String json, final boolean withinLockin) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + final Type typeOfMap = new TypeToken>() { + + }.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, withinLockin + ? Set.of("shortfallBorneBy", "toSavingsAccountNo", "closeDate", "dateFormat", "locale", "lockinOverrideReason", "sundryGlAccountId") + : Set.of("shortfallBorneBy", "toSavingsAccountNo", "closeDate", "dateFormat", "locale", "sundryGlAccountId")); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("party"); + final JsonElement element = this.fromApiJsonHelper.parse(json); + + v.reset().parameter("shortfallBorneBy").value(this.fromApiJsonHelper.extractStringNamed("shortfallBorneBy", element)).notBlank() + .isOneOfTheseStringValues(List.of("CUSTOMER", "AGENT")); + + // Required, as it is on an ordinary close. Both close the account, so neither should be the + // one that quietly decides the date for itself. + v.reset().parameter("closeDate").value(this.fromApiJsonHelper.extractLocalDateNamed("closeDate", element)).notNull(); + + if (this.fromApiJsonHelper.parameterExists("toSavingsAccountNo", element)) { + v.reset().parameter("toSavingsAccountNo").value(this.fromApiJsonHelper.extractStringNamed("toSavingsAccountNo", element)) + .ignoreIfNull().notExceedingLengthOf(50); + } + + if (withinLockin) { + v.reset().parameter("lockinOverrideReason").value(this.fromApiJsonHelper.extractStringNamed("lockinOverrideReason", element)) + .notBlank().notExceedingLengthOf(250); + } + + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + + public void validateForPayout(final String json) { + if (StringUtils.isBlank(json)) { + return; + } + final Type typeOfMap = new TypeToken>() { + + }.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, Set.of("toSavingsAccountNo", "dateFormat", "locale", "sundryGlAccountId")); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("party"); + final JsonElement element = this.fromApiJsonHelper.parse(json); + + if (this.fromApiJsonHelper.parameterExists("toSavingsAccountNo", element)) { + v.reset().parameter("toSavingsAccountNo").value(this.fromApiJsonHelper.extractStringNamed("toSavingsAccountNo", element)) + .ignoreIfNull().notExceedingLengthOf(50); + } + + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + + public void validateForClose(final String json) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + + final Type typeOfMap = new TypeToken>() { + + }.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, Set.of("closeDate", "dateFormat", "locale")); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("party"); + + final JsonElement element = this.fromApiJsonHelper.parse(json); + v.reset().parameter("closeDate").value(this.fromApiJsonHelper.extractLocalDateNamed("closeDate", element)).notNull(); + + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + + /** What the Adult/Minor field holds when the account is a minor's. */ + private static final String MINOR = "Minor"; + + /** + * A minor's account must name the guardian who will operate it. + * + * A minor cannot contract, so somebody has to stand for them - and until they come of age that + * person is who the bank deals with. Opening one with the box left empty leaves an account nobody + * is authorised to operate and no record of who was ever entitled to: the live data already + * carries two such accounts, opened before this ran. + * + * Checked here rather than on the screen alone, so an account cannot arrive without one by any + * other route. + */ + private void validateGuardianForMinor(final JsonElement element, final DataValidatorBuilder v) { + final String adultMinor = this.fromApiJsonHelper.extractStringNamed("adultMinor", element); + if (!MINOR.equalsIgnoreCase(adultMinor)) { + return; + } + v.reset().parameter("guardianClientId").value(this.fromApiJsonHelper.extractLongNamed("guardianClientId", element)).notNull() + .longGreaterThanZero(); + } + + /** + * How long the scheme runs for. Either a number of months, or - where the bank quotes a run of + * days instead - a number of days; one of the two has to be there or the account has no term and + * no maturity. + * + * Where months are given, days is worked out from them rather than taken from the request. + * Collection runs every day of the term, so the two can only ever disagree by someone mistyping, + * and a days figure below the term's own length quietly understated what the account would mature + * to. Anything sent in it is ignored. + */ + private void validateTermLength(final JsonElement element, final DataValidatorBuilder v) { + final Integer months = this.fromApiJsonHelper.extractIntegerSansLocaleNamed("month", element); + final Integer days = this.fromApiJsonHelper.extractIntegerSansLocaleNamed("days", element); + + if (months != null && months > 0) { + return; + } + // No months, so the days figure is the term and has to stand on its own. + v.reset().parameter("days").value(days).notNull().integerGreaterThanZero(); + } + + private void validateOptionalFields(final JsonElement element, final DataValidatorBuilder v) { + optionalString(element, v, "lastAccount", 50); + optionalString(element, v, "aadharNo", 20); + optionalString(element, v, "partyName1", 100); + optionalString(element, v, "address2", 200); + optionalString(element, v, "address3", 200); + optionalString(element, v, "gender", 20); + optionalString(element, v, "panCardNo", 20); + optionalString(element, v, "telephone", 20); + optionalString(element, v, "occupation", 40); + optionalString(element, v, "risk", 20); + optionalString(element, v, "ckycId", 40); + optionalString(element, v, "minAccountOpt", 40); + optionalString(element, v, "relation", 40); + optionalString(element, v, "ledgerNo", 40); + optionalString(element, v, "withdrawType", 20); + optionalString(element, v, "spDescription", 500); + optionalString(element, v, "freezeCode", 20); + optionalString(element, v, "advCode", 40); + optionalString(element, v, "creditToBranch", 100); + optionalString(element, v, "creditToAccountType", 100); + optionalString(element, v, "creditToAccount", 50); + optionalString(element, v, "nomineeSplitMode", 20); + optionalString(element, v, "savingsAccountNo", 20); + optionalString(element, v, "lienReason", 200); + + if (this.fromApiJsonHelper.parameterExists("guardianClientId", element)) { + v.reset().parameter("guardianClientId").value(this.fromApiJsonHelper.extractLongNamed("guardianClientId", element)) + .ignoreIfNull().integerGreaterThanZero(); + } + if (this.fromApiJsonHelper.parameterExists("month", element)) { + v.reset().parameter("month").value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("month", element)).ignoreIfNull() + .integerGreaterThanZero(); + } + if (this.fromApiJsonHelper.parameterExists("depositAmount", element)) { + v.reset().parameter("depositAmount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("depositAmount", element)) + .ignoreIfNull().zeroOrPositiveAmount(); + } + if (this.fromApiJsonHelper.parameterExists("paymentAmount", element)) { + v.reset().parameter("paymentAmount") + .value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("paymentAmount", element)).ignoreIfNull() + .zeroOrPositiveAmount(); + } + if (this.fromApiJsonHelper.parameterExists("openingBalance", element)) { + v.reset().parameter("openingBalance") + .value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("openingBalance", element)).ignoreIfNull() + .zeroOrPositiveAmount(); + } + if (this.fromApiJsonHelper.parameterExists("interestRate", element)) { + v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element)) + .ignoreIfNull().zeroOrPositiveAmount(); + } + } + + private void optionalString(final JsonElement element, final DataValidatorBuilder v, final String paramName, final int maxLength) { + if (this.fromApiJsonHelper.parameterExists(paramName, element)) { + v.reset().parameter(paramName).value(this.fromApiJsonHelper.extractStringNamed(paramName, element)).ignoreIfNull() + .notExceedingLengthOf(maxLength); + } + } + + /** + * 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 10 digits" tells + * the clerk what to type next, where "invalid" leaves them guessing at which of the ten + * characters the machine dislikes. + * + * Blank is left alone - whether the field is required at all is a separate question, answered + * by notBlank where it applies. + */ + private void mustLookLike(final DataValidatorBuilder v, final String paramName, final String value, final Pattern shape, + final String describedAs) { + if (StringUtils.isBlank(value)) { + return; + } + // matchesRegularExpression with a message puts that message where the clerk actually reads + // it. failWithCode leaves it in the args and shows "Failed data validation due to: + // invalid.format" instead, which names the fault without naming the fix. + v.reset().parameter(paramName).value(value).matchesRegularExpression(shape.pattern(), describedAs); + } + + /** + * The identifiers, each against its own shape. + * + * Applied on create and update alike: a number corrected into a wrong one is no better than a + * wrong one typed first. + */ + private void validateIdentifierFormats(final JsonElement element, final DataValidatorBuilder v) { + mustLookLike(v, "mobileNoSms", this.fromApiJsonHelper.extractStringNamed("mobileNoSms", element), MOBILE_PATTERN, + "Mobile number must be 10 digits starting 6-9"); + mustLookLike(v, "telephone", this.fromApiJsonHelper.extractStringNamed("telephone", element), TELEPHONE_PATTERN, + "Telephone number may contain only digits, spaces and + - ( )"); + mustLookLike(v, "pincode", this.fromApiJsonHelper.extractStringNamed("pincode", element), PINCODE_PATTERN, + "Pincode must be 6 digits and cannot start with 0"); + mustLookLike(v, "aadharNo", this.fromApiJsonHelper.extractStringNamed("aadharNo", element), AADHAAR_PATTERN, + "Aadhaar must be 12 digits, or the masked form XXXXXXXX1234"); + mustLookLike(v, "panCardNo", this.fromApiJsonHelper.extractStringNamed("panCardNo", element), PAN_PATTERN, + "PAN must match format ABCDE1234F"); + mustLookLike(v, "ckycId", this.fromApiJsonHelper.extractStringNamed("ckycId", element), CKYC_PATTERN, + "CKYC ID must be 14 digits"); + } + + /** + * The nominees themselves, and - under a Percentage split - that they add up. + * + * A nomination that does not total the whole deposit is worse than no nomination at all: it + * reads as a decision the customer made, and leaves the remainder with nobody named to receive + * it. So the shares are checked as a set here rather than field by field, and the request is + * refused rather than saved as a partial split somebody would have to chase later. + * + * Under a Sequence split there is nothing to add up - the order the nominees are given in is + * the order the bank pays in, and no share is stated. + */ + private void validateNominees(final JsonObject topLevelJson, final List dataValidationErrors) { + final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("party.nominee"); + final var nomineesArray = topLevelJson.getAsJsonArray("nominees"); + // Named, never assumed. The split decides which of the two answers each nominee owes - a + // share or a place in the order - so a request that replaces the nominees without stating it + // was being read as Sequence simply because it was not Percentage. On an update that sends + // only the nominee list, that silently re-reads a Percentage nomination as an order of + // entitlement: the one thing about a nomination nobody should be able to change by omission. + final String splitMode = this.fromApiJsonHelper.extractStringNamed("nomineeSplitMode", topLevelJson); + v.reset().parameter("nomineeSplitMode").value(splitMode).notBlank() + .isOneOfTheseStringValues(List.of(PERCENTAGE_SPLIT, SEQUENCE_SPLIT)); + if (StringUtils.isBlank(splitMode)) { + // Nothing below can be judged without it, and guessing is what this exists to stop. + return; + } + final boolean byPercentage = PERCENTAGE_SPLIT.equalsIgnoreCase(splitMode); + + // Taken once from the top level, the way the rest of CBS reads dates inside a submitted + // array - the payload states one format and every date in it is read that way. + final String dateFormat = this.fromApiJsonHelper.extractDateFormatParameter(topLevelJson); + final Locale locale = this.fromApiJsonHelper.extractLocaleParameter(topLevelJson); + + BigDecimal totalShare = BigDecimal.ZERO; + final Set sequencesSeen = new HashSet<>(); + boolean everySequenceStated = true; + + for (int i = 0; i < nomineesArray.size(); i++) { + final JsonObject nomineeJson = nomineesArray.get(i).getAsJsonObject(); + this.fromApiJsonHelper.checkForUnsupportedParameters(nomineeJson, SUPPORTED_NOMINEE_PARAMETERS); + + v.reset().parameter(NOMINEES).parameterAtIndexArray("nominationDate", i) + .value(extractString(nomineeJson, "nominationDate")).notBlank(); + v.reset().parameter(NOMINEES).parameterAtIndexArray("name", i).value(extractString(nomineeJson, "name")).notBlank() + .notExceedingLengthOf(100); + v.reset().parameter(NOMINEES).parameterAtIndexArray("relation", i).value(extractString(nomineeJson, "relation")).notBlank() + .notExceedingLengthOf(40); + v.reset().parameter(NOMINEES).parameterAtIndexArray("gender", i).value(extractString(nomineeJson, "gender")).notBlank() + .notExceedingLengthOf(20); + + // A nominee the bank cannot reach, or whose age it does not know, is a name and nothing + // more - and the age is what decides whether a guardian is needed at all. + v.reset().parameter(NOMINEES).parameterAtIndexArray("mobileNo", i).value(extractString(nomineeJson, "mobileNo")).notBlank() + .notExceedingLengthOf(20); + // Held to the same shape as the customer's own. A nominee reached by SMS is the point of + // taking the number at all. + mustLookLike(v, NOMINEES, extractString(nomineeJson, "mobileNo"), MOBILE_PATTERN, + "Nominee mobile number must be 10 digits starting 6-9"); + v.reset().parameter(NOMINEES).parameterAtIndexArray("birthDate", i).value(extractString(nomineeJson, "birthDate")) + .notBlank(); + + final LocalDate birthDate = this.fromApiJsonHelper.extractLocalDateNamed("birthDate", nomineeJson, dateFormat, locale); + final LocalDate nominationDate = this.fromApiJsonHelper.extractLocalDateNamed("nominationDate", nomineeJson, dateFormat, + locale); + + // Nobody is born after the day they are nominated. Caught here rather than left to read + // oddly later, because a date typed a year out is the kind of slip that only shows up + // when the guardian rule it drives turns out to have been decided on nonsense. + if (birthDate != null && nominationDate != null && birthDate.isAfter(nominationDate)) { + v.reset().parameter(NOMINEES).parameterAtIndexArray("birthDate", i).value(birthDate) + .failWithCode("cannot.be.after.the.nomination.date"); + } + + // A minor cannot receive the proceeds themselves, so somebody has to be named who can. + // Driven by the date of birth rather than by a separate answer, which is the whole point + // of no longer asking for one. + if (PartyNominee.isUnderAgeOn(birthDate, nominationDate)) { + v.reset().parameter(NOMINEES).parameterAtIndexArray("guardianName", i) + .value(extractString(nomineeJson, "guardianName")).notBlank().notExceedingLengthOf(100); + } + + if (byPercentage) { + final BigDecimal share = extractBigDecimal(nomineeJson, "sharePercent"); + v.reset().parameter(NOMINEES).parameterAtIndexArray("sharePercent", i).value(share).notNull().positiveAmount(); + if (share != null) { + totalShare = totalShare.add(share); + } + } else { + final Integer sequence = extractInteger(nomineeJson, "sequenceNo"); + v.reset().parameter(NOMINEES).parameterAtIndexArray("sequenceNo", i).value(sequence).notNull() + .inMinMaxRange(1, nomineesArray.size()); + if (sequence == null) { + everySequenceStated = false; + } else if (!sequencesSeen.add(sequence)) { + v.reset().parameter(NOMINEES).failWithCode("order.used.more.than.once", sequence); + } + } + } + + if (byPercentage && nomineesArray.size() > 0 && totalShare.compareTo(HUNDRED_PERCENT) != 0) { + v.reset().parameter(NOMINEES).value(totalShare).failWithCode("shares.must.total.one.hundred", totalShare); + } + + // Every place in the order filled exactly once. Checked as a set rather than per nominee + // because a gap is only visible across the whole list - 1 and 3 are each valid on their own, + // and together they leave nobody second. + if (!byPercentage && everySequenceStated && sequencesSeen.size() != nomineesArray.size()) { + v.reset().parameter(NOMINEES).failWithCode("order.must.run.from.one.without.gaps", nomineesArray.size()); + } + } + + 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 Integer extractInteger(final JsonObject json, final String paramName) { + return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsInt() : null; + } + + private void throwExceptionIfValidationWarningsExist(final List dataValidationErrors) { + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", + dataValidationErrors); + } + } + + /** + * That a held account says what it is held for. + * + * The flag alone freezes the money and never says why, which is how a lien 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. + */ + private void validateLien(final JsonElement element, final DataValidatorBuilder v) { + if (!Boolean.TRUE.equals(this.fromApiJsonHelper.extractBooleanNamed("lien", element))) { + // Not held, so there is nothing to explain. Releasing one needs no reason either: what + // it was held for is already on the record, and who released it and when is on the + // command the release rode in on. + return; + } + final String reason = this.fromApiJsonHelper.extractStringNamed("lienReason", element); + v.reset().parameter("lienReason").value(reason).notBlank().notExceedingLengthOf(200); + } + +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformService.java new file mode 100644 index 000000000..223a653f4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformService.java @@ -0,0 +1,63 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.party.service; + +import java.util.Collection; +import java.util.Map; +import org.apache.fineract.portfolio.dds.party.data.PartyData; + +public interface PartyReadPlatformService { + + /** + * The customer's accounts a DDS maturity may be paid into. + * + * The same rules the payout enforces, applied here so a screen cannot offer a choice the server + * will refuse: an active savings account of theirs, not a fixed or recurring deposit, and not + * another of their DDS scheme accounts. Working it out here rather than in each screen keeps the + * list and the refusal in step - they drifted apart once already, and the accounts on offer + * included a scheme account belonging to a party that had already closed. + */ + Collection> retrieveCreditableAccounts(Long clientId); + + /** + * How a closed account was closed - early or at maturity, by whom, what the balance came to and + * where it went - gathered from its transactions, the ledger and the command that closed it. + */ + Map retrieveClosure(Long partyId); + + /** + * @param agentId + * when given, only the parties this agent collects for - the daily transaction screen + * works one agent at a time, and a bank's whole book is far more than it needs. + */ + Collection retrieveAll(Long agentId); + + PartyData retrieveOne(Long partyId); + + /** + * The accounts whose term has run out and which are still open - the branch's list of closures + * waiting to be done. + * + * Nothing closes itself. A matured account is money the bank now owes the customer, and paying it + * out means agreeing with them where it should go, so it stays open and on this list until a + * clerk deals with it. Before this list existed the maturity date simply passed and nobody was + * told. + */ + Collection retrieveDueForClosure(); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformServiceImpl.java new file mode 100644 index 000000000..be95d0cc4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformServiceImpl.java @@ -0,0 +1,508 @@ +/** + * 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.service; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.ResultSet; +import java.sql.SQLException; +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.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.dds.calculation.DdsSchedule; +import org.apache.fineract.portfolio.dds.party.data.PartyData; +import org.apache.fineract.portfolio.dds.party.data.PartyNomineeData; +import org.apache.fineract.portfolio.dds.party.exception.PartyNotFoundException; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +@RequiredArgsConstructor +public class PartyReadPlatformServiceImpl implements PartyReadPlatformService { + + private final JdbcTemplate jdbcTemplate; + + private static final PartyMapper PARTY_MAPPER = new PartyMapper(); + private static final PartyNomineeMapper NOMINEE_MAPPER = new PartyNomineeMapper(); + + private static final class PartyMapper implements RowMapper { + + public String schema() { + return " p.id as id, p.client_id as clientId, c.display_name as clientName, p.agent_id as agentId, " + + " a.agent_code as agentCode, a.agent_name as agentName, p.office_id as officeId, o.name as officeName, " + + " p.savings_account_id as savingsAccountId, " + + " sa.account_no as savingsAccountNo, sp.name as savingsProductName, " + + " sa.account_balance_derived as savingsAccountBalance, " + + " coalesce(sa.on_hold_funds_derived, 0) + coalesce(sa.total_savings_amount_on_hold, 0) as amountHeldUnderLien, " + // How many instalments should have been paid by today, and how much actually has + // been. The first instalment 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 behind. + // + // Only PARTY_TRAN counts. Interest is the bank's to pay and a transfer in from + // another party is not this customer keeping to their schedule; counting either + // would show an account as up to date on days nobody collected. + + " least(greatest((current_date - p.opening_date) + 1, 0), coalesce(p.days, 0)) as instalmentsDue, " + + " coalesce((select sum(ct.amount) from m_dds_party_transaction ct " + + " where ct.party_id = p.id and ct.transaction_type = 'PARTY_TRAN' " + + " and ct.direction = 'CREDIT'), 0) as collectedToDate, " + + " case sa.status_enum when 100 then 'Submitted' when 200 then 'Approved' when 300 then 'Active' " + + " when 600 then 'Closed' when 700 then 'Rejected' end as savingsAccountStatus, " + + " p.sub_type as subType, t.account_type_description as subTypeDescription, " + + " p.party_code as partyCode, p.account_no as accountNo, p.last_account as lastAccount, p.nominal as nominal, " + + " p.aadhar_no as aadharNo, p.party_name as partyName, p.party_name1 as partyName1, p.address1 as address1, " + + " p.address2 as address2, p.address3 as address3, p.city as city, p.pincode as pincode, p.gender as gender, " + + " p.pan_card_no as panCardNo, p.telephone as telephone, p.mobile_no_sms as mobileNoSms, p.operation as operation, " + + " p.birth_date as birthDate, p.occupation as occupation, p.risk as risk, p.ckyc_id as ckycId, " + + " p.adult_minor as adultMinor, p.minor_date as minorDate, p.guardian_client_id as guardianClientId, " + // When a minor account's holder comes of age, and whether that day has passed. + // Worked out from the birth date rather than read off minor_date, which is filled + // in by hand and empty on every record so far. Eighteen is majority in India. + // + // The guardian's authority to operate the account ends on that day by law, so the + // branch has to take fresh KYC, a signature and a new mandate from the customer + // themselves. Nothing here acts on it - it only makes it visible, because until + // now the day passed with nobody being told. + + " case when lower(p.adult_minor) = 'minor' and p.birth_date is not null " + + " then p.birth_date + interval '18 years' end as majorOn, " + + " case when lower(p.adult_minor) = 'minor' and p.birth_date is not null " + + " then p.birth_date + interval '18 years' <= current_date else false end as minorTurnedMajor, " + + " p.min_account_opt as minAccountOpt, p.relation as relation, p.opening_date as openingDate, " + + " p.months as month, p.maturity_date as maturityDate, p.reopen_date as reopenDate, " + + " p.deposit_amount as depositAmount, p.daily_deposit_amount as dailyDepositAmount, p.days as days, " + + " p.ledger_no as ledgerNo, p.maturity_amount as maturityAmount, p.interest_rate as interestRate, " + + " p.withdraw_type as withdrawType, p.od1_flag as od1Flag, p.od2_flag as od2Flag, p.lien as lien, " + + " p.lien_reason as lienReason, " + + " p.pay_date as payDate, p.payment_amount as paymentAmount, p.current_balance as currentBalance, " + + " p.opening_balance as openingBalance, p.is_sms_active as isSmsActive, p.is_email_active as isEmailActive, " + + " p.interest_calc as interestCalc, p.interest_date as interestDate, " + + " p.od_interest_calc as odInterestCalc, p.od_interest_date as odInterestDate, p.sp_description as spDescription, " + + " p.freeze_code as freezeCode, p.adv_code as advCode, p.credit_to_branch as creditToBranch, " + + " p.credit_to_account_type as creditToAccountType, p.credit_to_account as creditToAccount, " + + " p.nominee_applicable as nomineeApplicable, p.nominee_split_mode as nomineeSplitMode, " + + " p.status as status, p.close_date as closeDate " + // Left join: sub_type is a plain code column rather than a foreign key, so a party + // whose account type was renamed away or predates the master still lists, just + // without a description. + + " from m_dds_party p join m_client c on c.id = p.client_id join m_dds_agent a on a.id = p.agent_id " + // Left join: an account written before it carried a branch of its own has none. + + " left join m_office o on o.id = p.office_id " + + " left join m_dds_account_type t on t.account_type_code = p.sub_type" + // The customer's real deposit account. Left join: parties opened before DDS began + // opening real accounts have none, and they still have to list. + + " left join m_savings_account sa on sa.id = p.savings_account_id" + + " left join m_savings_product sp on sp.id = sa.product_id"; + } + + @Override + public PartyData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final BigDecimal daily = rs.getBigDecimal("dailyDepositAmount"); + final int instalmentsDue = rs.getInt("instalmentsDue"); + final BigDecimal collected = rs.getBigDecimal("collectedToDate"); + final DdsSchedule schedule = DdsSchedule.of(daily, instalmentsDue, collected); + + return PartyData.builder() // + .instalmentsDue(instalmentsDue) // + .expectedToDate(schedule.expected()) // + .collectedToDate(collected) // + .advanceAmount(schedule.advance()) // + .advanceDays(schedule.advanceDays()) // + .arrearsDays(schedule.arrearsDays()) // + .recoverableAmount(schedule.recoverable()) // + .skippedDays(schedule.skippedDays()) // + .skippedAmount(schedule.skipped()) // + .id(rs.getLong("id")) // + .clientId(rs.getLong("clientId")) // + .clientName(rs.getString("clientName")) // + .agentId(rs.getLong("agentId")) // + .agentCode(rs.getString("agentCode")) // + .officeId(JdbcSupport.getLong(rs, "officeId")) // + .officeName(rs.getString("officeName")) // + .agentName(rs.getString("agentName")) // + .savingsAccountId(JdbcSupport.getLong(rs, "savingsAccountId")) // + .savingsAccountNo(rs.getString("savingsAccountNo")) // + .savingsProductName(rs.getString("savingsProductName")) // + .savingsAccountStatus(rs.getString("savingsAccountStatus")) // + .savingsAccountBalance(rs.getBigDecimal("savingsAccountBalance")) // + .amountHeldUnderLien(rs.getBigDecimal("amountHeldUnderLien")) // + .subType(rs.getString("subType")) // + .subTypeDescription(rs.getString("subTypeDescription")) // + .partyCode(rs.getString("partyCode")) // + .accountNo(rs.getString("accountNo")) // + .lastAccount(rs.getString("lastAccount")) // + .nominal(rs.getBoolean("nominal")) // + .aadharNo(rs.getString("aadharNo")) // + .partyName(rs.getString("partyName")) // + .partyName1(rs.getString("partyName1")) // + .address1(rs.getString("address1")) // + .address2(rs.getString("address2")) // + .address3(rs.getString("address3")) // + .city(rs.getString("city")) // + .pincode(rs.getString("pincode")) // + .gender(rs.getString("gender")) // + .panCardNo(rs.getString("panCardNo")) // + .telephone(rs.getString("telephone")) // + .mobileNoSms(rs.getString("mobileNoSms")) // + .operation(rs.getString("operation")) // + .birthDate(JdbcSupport.getLocalDate(rs, "birthDate")) // + .occupation(rs.getString("occupation")) // + .risk(rs.getString("risk")) // + .ckycId(rs.getString("ckycId")) // + .adultMinor(rs.getString("adultMinor")) // + .minorDate(JdbcSupport.getLocalDate(rs, "minorDate")) // + .majorOn(JdbcSupport.getLocalDate(rs, "majorOn")) // + .minorTurnedMajor(rs.getBoolean("minorTurnedMajor")) // + .guardianClientId(JdbcSupport.getLong(rs, "guardianClientId")) // + .minAccountOpt(rs.getString("minAccountOpt")) // + .relation(rs.getString("relation")) // + .openingDate(JdbcSupport.getLocalDate(rs, "openingDate")) // + .month(JdbcSupport.getInteger(rs, "month")) // + .maturityDate(JdbcSupport.getLocalDate(rs, "maturityDate")) // + .reopenDate(JdbcSupport.getLocalDate(rs, "reopenDate")) // + .depositAmount(rs.getBigDecimal("depositAmount")) // + .dailyDepositAmount(rs.getBigDecimal("dailyDepositAmount")) // + .days(JdbcSupport.getInteger(rs, "days")) // + .ledgerNo(rs.getString("ledgerNo")) // + .maturityAmount(rs.getBigDecimal("maturityAmount")) // + .interestRate(rs.getBigDecimal("interestRate")) // + .withdrawType(rs.getString("withdrawType")) // + .od1Flag(rs.getBoolean("od1Flag")) // + .od2Flag(rs.getBoolean("od2Flag")) // + .lien(rs.getBoolean("lien")) // + .lienReason(rs.getString("lienReason")) // + .payDate(JdbcSupport.getLocalDate(rs, "payDate")) // + .paymentAmount(rs.getBigDecimal("paymentAmount")) // + .currentBalance(rs.getBigDecimal("currentBalance")) // + .openingBalance(rs.getBigDecimal("openingBalance")) // + .isSmsActive(rs.getBoolean("isSmsActive")) // + .isEmailActive(rs.getBoolean("isEmailActive")) // + .interestCalc(rs.getBigDecimal("interestCalc")) // + .odInterestCalc(rs.getBigDecimal("odInterestCalc")) // + .odInterestDate(JdbcSupport.getLocalDate(rs, "odInterestDate")) // + .interestDate(JdbcSupport.getLocalDate(rs, "interestDate")) // + .spDescription(rs.getString("spDescription")) // + .freezeCode(rs.getString("freezeCode")) // + .advCode(rs.getString("advCode")) // + .creditToBranch(rs.getString("creditToBranch")) // + .creditToAccountType(rs.getString("creditToAccountType")) // + .creditToAccount(rs.getString("creditToAccount")) // + .nomineeApplicable(rs.getBoolean("nomineeApplicable")) // + .nomineeSplitMode(rs.getString("nomineeSplitMode")) // + .status(rs.getString("status")) // + .closeDate(JdbcSupport.getLocalDate(rs, "closeDate")) // + .nominees(new ArrayList<>()) // + .build(); + } + } + + private static final class PartyNomineeMapper implements RowMapper> { + + public String schema() { + return " n.party_id as partyId, n.id as id, n.nomination_date as nominationDate, n.name as name, " + + " n.mobile_no as mobileNo, n.birth_date as birthDate, n.minor as minor, n.relation as relation, " + + " n.gender as gender, n.guardian_name as guardianName, n.share_percent as sharePercent, " + + " n.sequence_no as sequenceNo from m_dds_party_nominee n"; + } + + @Override + public Map.Entry mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long partyId = rs.getLong("partyId"); + final PartyNomineeData data = PartyNomineeData.instance(rs.getLong("id"), JdbcSupport.getLocalDate(rs, "nominationDate"), + rs.getString("name"), rs.getString("mobileNo"), JdbcSupport.getLocalDate(rs, "birthDate"), rs.getBoolean("minor"), + rs.getString("relation"), rs.getString("gender"), rs.getString("guardianName"), + // Read as objects, not primitives: whichever split an account was nominated + // under, the other's column is null, and getInt would turn that into a zero + // that reads as a real answer. + rs.getBigDecimal("sharePercent"), JdbcSupport.getInteger(rs, "sequenceNo")); + return Map.entry(partyId, data); + } + } + + @Override + public Collection retrieveAll(final Long agentId) { + final List parties; + if (agentId == null) { + final String sql = "select " + PARTY_MAPPER.schema() + " order by p.id"; + parties = this.jdbcTemplate.query(sql, PARTY_MAPPER); // NOSONAR + } else { + final String sql = "select " + PARTY_MAPPER.schema() + " where p.agent_id = ? order by p.party_code"; + parties = this.jdbcTemplate.query(sql, PARTY_MAPPER, agentId); // NOSONAR + } + return attachNominees(parties); + } + + /** + * The accounts whose term has run out and which are still open, oldest maturity first. + * + * Oldest first because the one that matured longest ago is the one the bank has been holding + * money on for longest. Accounts still within their term are not here, and neither are ones + * already closed - this is a list of work outstanding, not a record of what happened. + */ + @Override + public Collection retrieveDueForClosure() { + // Matured as of the bank's business date - the date the payout itself checks - not the + // server's clock, so the list never offers an account the payout would call not yet matured. + final String sql = "select " + PARTY_MAPPER.schema() + + " where p.status = 'ACTIVE' and p.maturity_date is not null and p.maturity_date <= ?" + + " order by p.maturity_date, p.party_code"; + return attachNominees(this.jdbcTemplate.query(sql, PARTY_MAPPER, DateUtils.getBusinessLocalDate())); // NOSONAR + } + + @Override + public PartyData retrieveOne(final Long partyId) { + try { + final String sql = "select " + PARTY_MAPPER.schema() + " where p.id = ?"; + final PartyData party = this.jdbcTemplate.queryForObject(sql, PARTY_MAPPER, partyId); // NOSONAR + return attachNominees(List.of(party)).iterator().next(); + } catch (final EmptyResultDataAccessException e) { + throw new PartyNotFoundException(partyId, e); + } + } + + private Collection attachNominees(final List parties) { + if (parties.isEmpty()) { + return parties; + } + + // Ordered by the stated sequence where there is one, and otherwise by the order the names + // were entered. Under a Percentage split every sequence is null, so the first term is + // constant and the entry order stands; under a Sequence split it is the whole answer. + final String sql = "select " + NOMINEE_MAPPER.schema() + " order by coalesce(n.sequence_no, 0), n.id"; + final List> rows = this.jdbcTemplate.query(sql, NOMINEE_MAPPER); // NOSONAR + + final Map> nomineesByPartyId = new LinkedHashMap<>(); + for (final Map.Entry row : rows) { + nomineesByPartyId.computeIfAbsent(row.getKey(), k -> new ArrayList<>()).add(row.getValue()); + } + + final List result = new ArrayList<>(parties.size()); + for (final PartyData p : parties) { + result.add(p.toBuilder().nominees(nomineesByPartyId.getOrDefault(p.getId(), new ArrayList<>())).build()); + } + return result; + } + + /** + * Accounts of this customer's that a DDS maturity can actually be paid into. + * + * The conditions are the ones the payout itself applies, written once here so the choice on + * screen and the answer from the server cannot disagree: their own account, active, a savings + * account rather than a fixed or recurring deposit, and not one of their DDS scheme accounts - + * those are collected on daily and pay out at their own maturity, so a maturity sent into one + * would move money between two collection accounts instead of handing it back. + */ + // Aliases are quoted: unquoted ones are folded to lower case by the database, and the keys would + // reach the screen as accountno and productname, which read back as blank. + // + // Current accounts are offered as well: they keep their own table, and a maturity can be paid into + // one as surely as into a savings account - the payout checks the same things of it, open and the + // customer's own. + private static final String CREDITABLE_ACCOUNTS_SQL = "select * from (" + + "select a.id as id, a.account_no as \"accountNo\", " + + "p.name as \"productName\", a.account_balance_derived as balance, 'SAVINGS' as \"accountKind\" from m_savings_account a " + + "join m_savings_product p on p.id = a.product_id " + + "where a.client_id = ? and a.status_enum = 300 and p.deposit_type_enum = 100 " + + "and a.product_id is distinct from (select cast(value as bigint) from c_configuration " + + "where name = 'dds-deposit-product' and enabled = true) " + + "union all " + + "select c.id as id, c.account_no as \"accountNo\", cp.name as \"productName\", c.account_balance_derived as balance, " + + "'CURRENT' as \"accountKind\" from m_current_account c join m_current_product cp on cp.id = c.product_id " + + "where c.client_id = ? and c.status_enum = 300" + + ") accounts order by \"accountNo\""; + + @Override + public Collection> retrieveCreditableAccounts(final Long clientId) { + return this.jdbcTemplate.queryForList(CREDITABLE_ACCOUNTS_SQL, clientId, clientId); // NOSONAR + } + + private static final String CLOSURE_PARTY_SQL = "select p.party_code as \"partyCode\", p.status, p.close_date as \"closeDate\", " + + "a.agent_code as \"agentCode\" from m_dds_party p left join m_dds_agent a on a.id = p.agent_id where p.id = ?"; + + private static final String CLOSURE_PAYOUT_SQL = "select transaction_type as \"type\", amount, transaction_date as \"date\", " + + "account_no as \"accountNo\", description, voucher_no as \"voucherNo\" from m_dds_party_transaction where party_id = ? " + + "and transaction_type in ('MATURITY_PAYOUT', 'PREMATURE_PAYOUT') order by id desc limit 1"; + + private static final String CLOSURE_ADJUSTMENTS_SQL = "select " + + "coalesce(sum(case when transaction_type = 'PREMATURE_INT_RECOVERY' then amount end), 0) as \"recovered\", " + + "coalesce(sum(case when transaction_type = 'PREMATURE_AGENT_TOP_UP' then amount end), 0) as \"toppedUp\" " + + "from m_dds_party_transaction where party_id = ?"; + + /** + * The commands that closed the account. Party ids can come round again after test data is cleared, + * so only commands made since this party was opened are read - an older one belongs to someone else. + */ + private static final String CLOSURE_COMMANDS_SQL = "select c.action_name as \"action\", c.command_as_json as \"json\", " + + "c.result, mk.username as \"maker\", ck.username as \"checker\" " + + "from m_portfolio_command_source c left join m_appuser mk on mk.id = c.maker_id " + + "left join m_appuser ck on ck.id = c.checker_id where c.entity_name = 'PARTY' and c.resource_id = ? and c.status = 1 " + + "and c.action_name in ('PAYOUT', 'CLOSE', 'CLOSEEARLY', 'PREMATURECLOSEOVERRIDE') " + + "and c.made_on_date_utc >= (select created_date from m_dds_party where id = ?) order by c.id"; + + /** Both sides of the payout on the ledger - where it left and where it landed. */ + private static final String CLOSURE_LEDGER_SQL = "select j.transaction_id as \"transactionId\", g.gl_code as \"glCode\", " + + "g.name as \"glName\", case j.type_enum when 1 then 'CR' else 'DR' end as \"side\", j.amount " + + "from acc_gl_journal_entry j join acc_gl_account g on g.id = j.account_id " + + "where j.description = ? and j.entry_date = ? and j.reversed = false order by j.id"; + + private static final String CREDITED_ACCOUNT_SQL = "select s.account_no as \"accountNo\", sp.name as \"productName\", " + + "c.display_name as \"owner\" from m_savings_account s join m_savings_product sp on sp.id = s.product_id " + + "left join m_client c on c.id = s.client_id where s.account_no = ? " + + "union all select ca.account_no, cp.name, c.display_name from m_current_account ca " + + "join m_current_product cp on cp.id = ca.product_id left join m_client c on c.id = ca.client_id where ca.account_no = ?"; + + private static final String ACTION_CLOSE = "CLOSE"; + private static final String ACTION_PAYOUT = "PAYOUT"; + + @Override + public Map retrieveClosure(final Long partyId) { + final List> parties = this.jdbcTemplate.queryForList(CLOSURE_PARTY_SQL, partyId); // NOSONAR + if (parties.isEmpty()) { + throw new PartyNotFoundException(partyId); + } + final Map party = parties.get(0); + final Map closure = new LinkedHashMap<>(); + final boolean closed = "CLOSED".equals(party.get("status")); + closure.put("closed", closed); + if (!closed) { + return closure; + } + final String partyCode = (String) party.get("partyCode"); + closure.put("closeDate", isoDate(party.get("closeDate"))); + closure.put("agentCode", party.get("agentCode")); + + final List> payouts = this.jdbcTemplate.queryForList(CLOSURE_PAYOUT_SQL, partyId); // NOSONAR + final Map payout = payouts.isEmpty() ? null : payouts.get(0); + final Map adjustments = this.jdbcTemplate.queryForMap(CLOSURE_ADJUSTMENTS_SQL, partyId); // NOSONAR + final BigDecimal recovered = (BigDecimal) adjustments.get("recovered"); + final BigDecimal toppedUp = (BigDecimal) adjustments.get("toppedUp"); + final BigDecimal paidOut = payout == null ? BigDecimal.ZERO : (BigDecimal) payout.get("amount"); + + // The command that did the closing, and a plain Close that followed a payout made the old way. + Map closingCommand = null; + Map laterClose = null; + for (final Map command : this.jdbcTemplate.queryForList(CLOSURE_COMMANDS_SQL, partyId, partyId)) { // NOSONAR + if (ACTION_CLOSE.equals(command.get("action")) && closingCommand != null) { + laterClose = command; + } else { + closingCommand = command; + laterClose = null; + } + } + final String closingAction = closingCommand == null ? null : (String) closingCommand.get("action"); + + final boolean premature = payout != null ? "PREMATURE_PAYOUT".equals(payout.get("type")) + : closingAction != null && !ACTION_CLOSE.equals(closingAction) && !ACTION_PAYOUT.equals(closingAction); + final String closureType; + if (premature) { + closureType = "PREMATURE"; + } else if (payout == null) { + closureType = "CLOSED_NIL_BALANCE"; + } else if (!ACTION_PAYOUT.equals(closingAction)) { + // Paid out with no clerk's command behind it: the nightly Maturity Auto Close. + closureType = "MATURITY_AUTO_CLOSE"; + } else { + closureType = laterClose != null ? "MATURITY_PAYOUT_THEN_CLOSE" : "MATURITY_PAYOUT_AND_CLOSE"; + } + closure.put("closureType", closureType); + closure.put("withinLockin", "PREMATURECLOSEOVERRIDE".equals(closingAction)); + + final Map actedBy = laterClose != null ? laterClose : closingCommand; + closure.put("closedBy", "MATURITY_AUTO_CLOSE".equals(closureType) ? "System" : actedBy == null ? null : actedBy.get("maker")); + closure.put("approvedBy", "MATURITY_AUTO_CLOSE".equals(closureType) || actedBy == null ? null : actedBy.get("checker")); + if (laterClose != null) { + closure.put("paidOutBy", closingCommand.get("maker")); + } + + final JsonObject request = jsonOf(closingCommand == null ? null : closingCommand.get("json")); + final JsonObject result = jsonOf(closingCommand == null ? null : closingCommand.get("result")); + final JsonObject changes = result != null && result.has("changes") && result.get("changes").isJsonObject() + ? result.getAsJsonObject("changes") + : null; + closure.put("lockinOverrideReason", stringOf(request, "lockinOverrideReason")); + closure.put("shortfallBorneBy", premature ? stringOf(request, "shortfallBorneBy") : null); + closure.put("agentCommissionPenalty", + changes != null && changes.has("agentCommissionPenalty") && !changes.get("agentCommissionPenalty").isJsonNull() + ? changes.get("agentCommissionPenalty").getAsBigDecimal() + : BigDecimal.ZERO); + + // What the account held when closing began: what went out, plus what was taken back, less what the agent put in. + closure.put("balanceAtClose", paidOut.add(recovered).subtract(toppedUp)); + closure.put("interestRecovered", recovered); + closure.put("interestToppedUpByAgent", toppedUp); + closure.put("paidOut", paidOut); + + if (payout != null) { + final String accountNo = (String) payout.get("accountNo"); + closure.put("payoutDate", isoDate(payout.get("date"))); + closure.put("passbookNote", payout.get("description")); + closure.put("voucherNo", payout.get("voucherNo")); + final String ledgerDescription = (premature ? "DDS premature closure payout for " : "DDS maturity payout for ") + partyCode; + final List> ledger = this.jdbcTemplate.queryForList(CLOSURE_LEDGER_SQL, ledgerDescription, // NOSONAR + payout.get("date")); + // Only the last posting, should the same account ever have been paid out twice. + final Object lastTransaction = ledger.isEmpty() ? null : ledger.get(ledger.size() - 1).get("transactionId"); + final List> legs = ledger.stream().filter(row -> row.get("transactionId").equals(lastTransaction)).toList(); + closure.put("ledgerTransactionId", lastTransaction); + closure.put("ledgerEntries", legs); + if (accountNo != null && !accountNo.isBlank()) { + closure.put("destination", "OWN_ACCOUNT"); + final List> accounts = this.jdbcTemplate.queryForList(CREDITED_ACCOUNT_SQL, accountNo, accountNo); // NOSONAR + closure.put("creditedAccount", accounts.isEmpty() ? Map.of("accountNo", accountNo) : accounts.get(0)); + } else { + closure.put("destination", "SUNDRY"); + legs.stream().filter(row -> "CR".equals(row.get("side"))).findFirst() + .ifPresent(row -> closure.put("sundryGl", Map.of("glCode", row.get("glCode"), "glName", row.get("glName")))); + } + } + return closure; + } + + /** A date from the database as yyyy-MM-dd, which the screen reads - a java.sql.Date will not serialise. */ + private static String isoDate(final Object value) { + if (value instanceof java.sql.Date date) { + return date.toLocalDate().toString(); + } + return value == null ? null : value.toString(); + } + + private static JsonObject jsonOf(final Object text) { + if (!(text instanceof String json) || json.isBlank()) { + return null; + } + try { + final JsonElement element = JsonParser.parseString(json); + return element.isJsonObject() ? element.getAsJsonObject() : null; + } catch (RuntimeException e) { + return null; + } + } + + private static String stringOf(final JsonObject json, final String name) { + return json != null && json.has(name) && !json.get(name).isJsonNull() ? json.get(name).getAsString() : null; + } +} + diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformService.java new file mode 100644 index 000000000..e9112c08e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformService.java @@ -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.portfolio.dds.party.service; + +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +public interface PartyWritePlatformService { + + CommandProcessingResult createParty(JsonCommand command); + + CommandProcessingResult updateParty(Long partyId, JsonCommand command); + + CommandProcessingResult closeParty(Long partyId, JsonCommand command); + + /** + * Opens the real deposit account of a party that predates DDS opening them, and brings its + * existing balance onto it. Run once per such party; refuses one that already has an account. + */ + CommandProcessingResult openDepositAccount(Long partyId); + + /** + * Pays a matured account out. The account stays open with a nil balance. + * + * Either into another account of the customer's at this bank, or - when they have none - into the + * matured-deposits payable account, from which the branch sends it on to their account at another + * bank. Refuses an account whose term has not run out, because paying one out early is a premature + * withdrawal and that carries terms of its own. + * + * Closing is deliberately not part of this. The clerk pays, sees the balance fall to nil, and + * closes as a second act - so a payout that goes wrong leaves an open account to correct rather + * than a closed one to reopen. + */ + CommandProcessingResult payOut(Long partyId, JsonCommand command); + + /** + * Pays a matured account into the account the customer already named, and closes it. + * + * This is what a Maturity Auto Close batch run applies, one account at a time. It does in one act + * what {@link #payOut} and {@link #closeParty} do in two, which that pair deliberately does not - + * and the reason it may is that the review those two rely on has already happened. A batch run is + * generated first and lists every account it would settle, with the amount and the account it + * would go into; nothing runs until a clerk has read that list and pressed Post. The check is + * made earlier than on the manual path rather than skipped. + * + * Only an account that named a destination when it was opened is settled this way - the batch + * does not look for one, and never picks. An account that named none is simply not on the list + * and is paid out by hand exactly as before. + */ + CommandProcessingResult closeAtMaturity(Long partyId); + + /** + * Closes an account before its term is up, and pays out what is left. + * + * A customer who stops saving early costs the bank the interest it had promised on money it no + * longer has for as long as it expected. Somebody has to carry that, and the bank allows two + * answers - so the caller says which: + * + *
+     *   CUSTOMER   their interest is recalculated at a lower rate and the difference taken back
+     *   AGENT      the customer keeps a full month's interest and the agent funds the rest of it
+     * 
+ * + * The agent is charged a share of their commission either way: an account closed early is a round + * they did not keep going, whoever ends up bearing the interest. + * + * What is charged, and how long an account must run before it can be closed early at all, belong + * to the scheme rather than to this code - read at the moment of closing, so terms the bank + * changes take effect on accounts already open. + * + * Unlike {@link #payOut}, this closes the account itself. A matured account is paid and closed as + * two acts because the clerk should see the money land first; here the charge, the payout and the + * closure are one decision and splitting them would leave an account half-settled on terms that + * could then be changed underneath it. + */ + CommandProcessingResult prematureClose(Long partyId, JsonCommand command); + + /** + * Closes one account early inside its scheme's lock-in, for a reason a manager approves. The + * scheme's lock-in is not changed; only this account is let through it. + */ + CommandProcessingResult prematureCloseWithinLockin(Long partyId, JsonCommand command); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformServiceJpaRepositoryImpl.java new file mode 100644 index 000000000..0a425cfa3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformServiceJpaRepositoryImpl.java @@ -0,0 +1,1089 @@ +/** + * 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.service; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import jakarta.persistence.PersistenceException; +import java.math.BigDecimal; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import java.math.MathContext; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.Period; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +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.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.agent.domain.Agent; +import org.apache.fineract.organisation.agent.domain.AgentRepository; +import org.apache.fineract.organisation.agent.exception.AgentNotFoundException; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountType; +import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService; +import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountTypeRepository; +import org.apache.fineract.portfolio.dds.party.domain.Party; +import org.apache.fineract.portfolio.dds.party.domain.PartyNominee; +import org.apache.fineract.portfolio.dds.party.domain.PartyRepository; +import org.apache.fineract.portfolio.dds.calculation.DdsInterestCalculation; +import org.apache.fineract.portfolio.dds.party.exception.PartyAlreadyClosedException; +import org.apache.fineract.portfolio.dds.party.exception.PartyAlreadyMaturedException; +import org.apache.fineract.portfolio.dds.party.exception.PartyWithinLockinPeriodException; +import org.apache.fineract.portfolio.dds.party.exception.PartyHasBalanceException; +import org.apache.fineract.portfolio.dds.party.exception.PartyCreditAccountUnusableException; +import org.apache.fineract.portfolio.dds.party.exception.PartyHeldUnderLienException; +import org.apache.fineract.portfolio.dds.party.exception.PartyMajorWithoutKycException; +import org.apache.fineract.portfolio.dds.party.exception.PartyNothingToPayOutException; +import org.apache.fineract.portfolio.dds.party.exception.PartyNotMaturedException; +import org.apache.fineract.portfolio.dds.party.exception.PartyNotFoundException; +import org.apache.fineract.portfolio.dds.party.exception.PartySavingsAccountClientMismatchException; +import org.apache.fineract.portfolio.dds.party.exception.PartySavingsAccountNotFoundException; +import org.apache.fineract.portfolio.dds.accounting.DdsLedger; +import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService; +import org.apache.fineract.portfolio.dds.party.exception.DdsDepositProductNotConfiguredException; +import org.apache.fineract.portfolio.dds.party.exception.PartyAccountAlreadyOpenException; +import org.apache.fineract.portfolio.dds.party.exception.PartySubTypeNotActiveException; +import org.apache.fineract.portfolio.dds.party.serialization.PartyCommandFromApiJsonDeserializer; +import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransaction; +import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository; +import org.apache.fineract.portfolio.savings.data.SavingsAccountDataDTO; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository; +import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService; +import org.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 PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePlatformService { + + private final PartyCommandFromApiJsonDeserializer fromApiJsonDeserializer; + private final PartyRepository partyRepository; + private final AgentRepository agentRepository; + private final ClientRepositoryWrapper clientRepositoryWrapper; + private final SavingsAccountRepository savingsAccountRepository; + private final DdsAccountTypeRepository ddsAccountTypeRepository; + + /** Opens the customer's real deposit account when a party is created - see openDepositAccountIfUnlinked. */ + private final SavingsApplicationProcessWritePlatformService savingsApplicationProcessWritePlatformService; + /** Puts an opening balance onto the bank's own books as well as DDS's - see DdsLedger. */ + private final DdsLedger ddsLedger; + /** The scheme's own ledger, which a maturity payout has to appear on like any other movement. */ + private final PartyTransactionRepository partyTransactionRepository; + /** Tops a matured account's interest up before it is paid out - see payOut. */ + private final BatchRunWritePlatformService batchRunWritePlatformService; + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + /** Reads the dates inside submitted arrays, which JsonCommand only reads at its top level. */ + private final FromJsonHelper fromApiJsonHelper; + /** Resolves the branch an account is opened at - the same wrapper the Agent screen uses. */ + private final OfficeRepositoryWrapper officeRepositoryWrapper; + + @Transactional + @Override + public CommandProcessingResult createParty(final JsonCommand command) { + + try { + this.fromApiJsonDeserializer.validateForCreate(command.json()); + + final Long clientId = command.longValueOfParameterNamed("clientId"); + final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId); + + final Long agentId = command.longValueOfParameterNamed("agentId"); + final Agent agent = this.agentRepository.findById(agentId) + .orElseThrow(() -> new AgentNotFoundException(agentId)); + + validateSubType(command.stringValueOfParameterNamed("subType")); + validateCreditToAccount(clientId, command); + + final String partyCode = generateNextPartyCode(); + final Party party = Party.fromJson(client, agent, officeNamed(command.longValueOfParameterNamed(OFFICE_ID)), partyCode, + command); + party.takeSchemeRate(schemeOf(party).getInterestRate()); + + linkSavingsAccountIfProvided(party, clientId, command); + addNomineesFromCommand(party, command); + + // The party is the DDS account: nothing is opened in the savings tables for it. Its + // number comes from its id, so it is given once the first save has made one. + this.partyRepository.saveAndFlush(party); + party.assignAccountNo(); + this.partyRepository.saveAndFlush(party); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(party.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 updateParty(final Long partyId, final JsonCommand command) { + + try { + this.fromApiJsonDeserializer.validateForUpdate(command.json()); + + final Party partyForUpdate = this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId)); + + // Checked against whoever owns the party once this update lands, not who owns it now - + // moving the party to another customer and naming the old one's account in the same save + // would otherwise pass. + final Long owningClientId = command.parameterExists("clientId") ? command.longValueOfParameterNamed("clientId") + : partyForUpdate.clientId(); + validateCreditToAccount(owningClientId, command); + + final Map changesOnly = partyForUpdate.update(command); + + // Only when it actually changed: a party already sitting on a since-retired account type + // must stay editable for everything else. + if (changesOnly.containsKey("subType")) { + validateSubType((String) changesOnly.get("subType")); + // Moved to another scheme: it takes that scheme's rate. + partyForUpdate.takeSchemeRate(schemeOf(partyForUpdate).getInterestRate()); + changesOnly.put("interestRate", partyForUpdate.getInterestRate()); + } + + if (changesOnly.containsKey("clientId")) { + final Long newClientId = (Long) changesOnly.get("clientId"); + final Client newClient = this.clientRepositoryWrapper.findOneWithNotFoundDetection(newClientId); + partyForUpdate.changeClient(newClient); + } + + if (changesOnly.containsKey("agentId")) { + final Long newAgentId = (Long) changesOnly.get("agentId"); + final Agent newAgent = this.agentRepository.findById(newAgentId) + .orElseThrow(() -> new AgentNotFoundException(newAgentId)); + partyForUpdate.changeAgent(newAgent); + } + + // The branch moves only when it is asked to. Changing the collecting agent deliberately + // does not take the account's branch with it - an agent of one branch may well collect + // for a customer of another, which is why the account carries a branch of its own. + if (command.parameterExists(OFFICE_ID)) { + partyForUpdate.changeOffice(officeNamed(command.longValueOfParameterNamed(OFFICE_ID))); + changesOnly.put(OFFICE_ID, command.longValueOfParameterNamed(OFFICE_ID)); + } + + if (command.parameterExists("nominees")) { + partyForUpdate.resetNominees(); + addNomineesFromCommand(partyForUpdate, command); + changesOnly.put("nominees", command.jsonFragment("nominees")); + } + + linkSavingsAccountIfProvided(partyForUpdate, partyForUpdate.clientId(), command); + + this.partyRepository.saveAndFlush(partyForUpdate); + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId).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(); + } + } + + @Transactional + @Override + public CommandProcessingResult closeParty(final Long partyId, final JsonCommand command) { + + this.fromApiJsonDeserializer.validateForClose(command.json()); + + final Party party = this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId)); + + if (party.isClosed()) { + throw new PartyAlreadyClosedException(party.getPartyCode()); + } + if (party.getCurrentBalance().compareTo(BigDecimal.ZERO) != 0) { + throw new PartyHasBalanceException(party.getPartyCode()); + } + + final LocalDate closeDate = command.localDateValueOfParameterNamed("closeDate"); + party.close(closeDate); + this.partyRepository.saveAndFlush(party); + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId).build(); + } + + /** + * If the caller gave a savingsAccountNo, links this Party to that real Savings Account - resolved + * by account number since that's what an officer actually has to hand, not the internal id. Left + * unlinked (as before) when the parameter isn't given: a Party doesn't require a real account, + * this is opportunistic linking for a client that already happens to have one. + */ + private void linkSavingsAccountIfProvided(final Party party, final Long expectedClientId, final JsonCommand command) { + if (!command.parameterExists("savingsAccountNo")) { + return; + } + final String savingsAccountNo = command.stringValueOfParameterNamed("savingsAccountNo"); + final SavingsAccount savingsAccount = this.savingsAccountRepository.findNonClosedAccountByAccountNumber(savingsAccountNo); + if (savingsAccount == null) { + throw new PartySavingsAccountNotFoundException(savingsAccountNo); + } + if (!expectedClientId.equals(savingsAccount.clientId())) { + throw new PartySavingsAccountClientMismatchException(savingsAccountNo); + } + party.linkSavingsAccount(savingsAccount.getId()); + } + + /** + * Opens the real deposit account of a party that predates DDS opening them, and brings whatever + * balance it already holds onto it. + * + * Parties created before this existed carry a balance DDS knows about and the bank's books do not. + * They cannot simply be left: every collection and interest posting from now on lands on a real + * account, so a party without one quietly stops being recorded anywhere but DDS. This is the + * catch-up, run once per party. + * + * Refuses a party that already has an account rather than opening a second one - two accounts for + * one customer is worse than none, because collections would start splitting between them. + */ + @Transactional + @Override + public CommandProcessingResult openDepositAccount(final Long partyId) { + final Party party = this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId)); + if (party.getSavingsAccountId() != null) { + throw new PartyAccountAlreadyOpenException(party.getPartyCode()); + } + + final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(party.clientId()); + openDepositAccountIfUnlinked(party, client); + + final BigDecimal balance = party.getCurrentBalance(); + if (balance != null && balance.compareTo(BigDecimal.ZERO) > 0) { + this.ddsLedger.openingBalance(party.getSavingsAccountId(), DateUtils.getBusinessLocalDate(), balance, + "DDS opening balance for " + party.getPartyCode()); + } + + this.partyRepository.saveAndFlush(party); + return new CommandProcessingResultBuilder().withEntityId(partyId).build(); + } + + /** + * Pays a matured account out. The account stays open. + * + *

+ * What is paid is what the account actually holds - the deposits that came in plus the interest + * already posted - not the figure quoted when it was opened. That quote assumes every instalment + * arrives; a customer who stopped paying halfway has not earned it, and no bank pays out money it + * never received. + * + *

+ * Two destinations, and the difference is whether the money leaves the bank: + * + *

+     *   toSavingsAccountNo   another account of theirs here - moved across, nothing leaves
+     *   no account given     parked in Matured Deposits Payable, for the branch to send on
+     * 
+ * + *

+ * Closing is deliberately not part of this. Paying out and closing are two decisions: the officer + * pays, sees the balance reach zero and the money land where it was meant to, and only then + * closes. Doing both on one click would close accounts whose payment had not been checked, and a + * closed account is far harder to put right than an open one with a balance. + */ + @Transactional + @Override + public CommandProcessingResult payOut(final Long partyId, final JsonCommand command) { + this.fromApiJsonDeserializer.validateForPayout(command.json()); + + final Party party = this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId)); + + refuseIfNotReadyForMaturity(party); + // One DDS voucher for the payout, the interest brought up to date first included. + this.ddsLedger.withVoucher(DdsVoucherService.TYPE_MATURITY_PAYOUT, DdsVoucherService.REF_PARTY, partyId, () -> { + settleMaturity(party, command.stringValueOfParameterNamedAllowingNull("toSavingsAccountNo"), + command.longValueOfParameterNamed("sundryGlAccountId")); + return null; + }); + + // Paid out is closed. The balance is nil and a matured account takes nothing more, so leaving it + // open only leaves a second step to forget - an account that reads as active in every report and + // that an agent could still collect on. The same as Maturity Auto Close does, on the day the + // money moved. + party.close(DateUtils.getBusinessLocalDate()); + this.partyRepository.saveAndFlush(party); + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId).build(); + } + + /** + * The conditions a matured account must meet before any of its money moves. + * + * Held apart from the paying itself because two callers ask the same question - the clerk on the + * payout screen and the Maturity Auto Close run - and a rule that lived in only one of them would + * be a rule the other silently did without. + */ + private void refuseIfNotReadyForMaturity(final Party party) { + if (party.isClosed()) { + throw new PartyAlreadyClosedException(party.getPartyCode()); + } + if (party.getMaturityDate() == null || party.getMaturityDate().isAfter(DateUtils.getBusinessLocalDate())) { + throw new PartyNotMaturedException(party.getPartyCode(), party.getMaturityDate()); + } + // No savings account is not a reason to refuse any more: a DDS account is the party itself + // (0514), and DdsLedger books it on DDS Customer Deposits. + refuseIfMajorWithoutKyc(party); + refuseIfHeldUnderLien(party); + } + + /** + * Brings the interest up to date and moves the balance out, leaving the account open and nil. + * + * @param toAccountNo + * the customer's own account to pay into, or null to park the money in the matured- + * deposits payable account for the branch to send on. + */ + private void settleMaturity(final Party party, final String toAccountNo, final Long sundryGlAccountId) { + // Interest up to the maturity date, before anything is paid. The nightly run normally has it + // already, and this returns having done nothing; it earns its place on the night that run did + // not happen - the server was down, or the scheme has automatic posting switched off. Without + // it a customer who paid every instalment still loses the last stretch of interest, and + // nothing on screen would say so. + this.batchRunWritePlatformService.postInterestThrough(party, party.getMaturityDate()); + + final BigDecimal amount = party.getCurrentBalance(); + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new PartyNothingToPayOutException(party.getPartyCode()); + } + + final LocalDate payoutDate = DateUtils.getBusinessLocalDate(); + final String description = "DDS maturity payout for " + party.getPartyCode(); + + final boolean toOwnAccount = toAccountNo != null && !toAccountNo.isBlank(); + GLAccount parkIn = null; + if (toOwnAccount) { + final PayableAccount toAccount = payableAccountId(party.clientId(), toAccountNo); + payOutToOwn(party, toAccount, payoutDate, amount, description); + } else { + parkIn = this.ddsLedger.sundryAccount(sundryGlAccountId); + this.ddsLedger.payOutToPayable(party, payoutDate, amount, description, parkIn); + } + + final BigDecimal balanceAfter = party.debit(amount); + this.partyRepository.saveAndFlush(party); + + // The scheme's own ledger has to show the money leaving, or it reads as fifteen deposits and + // a nil balance with nothing in between - and the statement a customer is handed would not + // account for where their money went. The bank's books already carry it; this is DDS's copy. + this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), payoutDate, MATURITY_PAYOUT, + DIRECTION_DEBIT, null, null, toOwnAccount ? toAccountNo : null, amount, null, null, + toOwnAccount ? "Maturity paid into " + toAccountNo : "Maturity parked in Sundry " + parkedIn(parkIn), balanceAfter)); + } + + @Transactional + @Override + public CommandProcessingResult closeAtMaturity(final Long partyId) { + final Party party = this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId)); + + refuseIfNotReadyForMaturity(party); + + // Checked again here, and not only when the run was generated. Time passes between the two, + // and the instruction can be withdrawn or the account behind it closed in between - so the + // account is read now rather than trusted from a list drawn up earlier. Without an + // instruction there is nothing this method could do but guess, and it does not guess. + if (!TRANSFER_TO_SAVING.equalsIgnoreCase(party.getAdvCode())) { + throw new PartyCreditAccountUnusableException(party.getPartyCode(), + "the account is not set to transfer its maturity proceeds to a savings account"); + } + final String toAccountNo = party.getCreditToAccount(); + if (toAccountNo == null || toAccountNo.isBlank()) { + throw new PartyCreditAccountUnusableException(party.getPartyCode(), "no account is named for the maturity proceeds"); + } + + this.ddsLedger.withVoucher(DdsVoucherService.TYPE_MATURITY_PAYOUT, DdsVoucherService.REF_PARTY, partyId, () -> { + settleMaturity(party, toAccountNo, null); + return null; + }); + + // The balance is nil now, which is the one thing closing insists on. Closed on the business + // date rather than the maturity date: this is the day the money actually moved, and a close + // date earlier than its own payout would read as though the account had been shut first. + party.close(DateUtils.getBusinessLocalDate()); + this.partyRepository.saveAndFlush(party); + + return new CommandProcessingResultBuilder().withEntityId(partyId).build(); + } + + /** The Sundry account a balance was parked in, as its passbook line names it. */ + private static String parkedIn(final GLAccount account) { + return account == null ? "" : account.getGlCode() + " " + account.getName(); + } + + /** How a maturity payout appears on the scheme's own ledger, beside PARTY_TRAN and INTEREST_POST. */ + private static final String MATURITY_PAYOUT = "MATURITY_PAYOUT"; + private static final String DIRECTION_DEBIT = "DEBIT"; + private static final String DIRECTION_CREDIT = "CREDIT"; + + /** + * The three ways an early closure shows on the scheme's own ledger. Kept as separate types rather + * than one PREMATURE row, because they are different movements to different people and a + * statement that merged them would not say who bore what. + */ + private static final String PREMATURE_INTEREST_RECOVERY = "PREMATURE_INT_RECOVERY"; + private static final String PREMATURE_AGENT_TOP_UP = "PREMATURE_AGENT_TOPUP"; + private static final String PREMATURE_PAYOUT = "PREMATURE_PAYOUT"; + + private static final String SHORTFALL_BORNE_BY = "shortfallBorneBy"; + private static final String LOCKIN_OVERRIDE_REASON = "lockinOverrideReason"; + private static final String CUSTOMER_BEARS = "CUSTOMER"; + private static final String AGENT_BEARS = "AGENT"; + + private static final String OFFICE_ID = "officeId"; + + private static final BigDecimal HUNDRED = BigDecimal.valueOf(100); + /** + * Money is held to paise here as it is everywhere else in DDS - the same scale + * {@link DdsInterestCalculation} and DdsCommissionCalculation round to. A closure figure is + * one a cashier has to hand over or take, so a third decimal is not a smaller amount, it is an + * amount that does not exist. + */ + private static final int MONEY_SCALE = 2; + + /** + * Interest this account has actually been credited. Reversals carry the same type with a DEBIT + * direction, so they net off and a second early closure cannot recover the same interest twice. + */ + private static final String INTEREST_CREDITED_SQL = "select coalesce(sum(case when direction = 'CREDIT' then amount " + + "else -amount end), 0) from m_dds_party_transaction " + + "where party_id = ? and transaction_type in ('INTEREST_POST', 'PREMATURE_INT_RECOVERY', 'PREMATURE_AGENT_TOPUP')"; + + /** + * Whether this customer's own KYC stands verified today. + * + * Read from the bank's own KYC record rather than from anything DDS keeps of its own, so the + * answer here is the answer the CKYC screens give. 300 is VERIFIED; an expiry that has passed is + * not verification any more, whatever the status still says. + */ + private static final String KYC_VERIFIED_SQL = "select count(*) from m_client_kyc " + + "where client_id = ? and kyc_status = 300 and (expiry_date is null or expiry_date >= current_date)"; + + /** What the Adult/Minor field holds while the account is a minor's. */ + private static final String MINOR = "Minor"; + private static final int MAJORITY_AGE = 18; + + /** Adv Code that sends the maturity proceeds to another account rather than paying them out. */ + private static final String TRANSFER_TO_SAVING = "Transfer To Saving"; + + /** + * Refuses to pay out a minor's account whose holder has come of age without their own KYC. + * + * Up to eighteen the guardian operates the account and the bank deals with them; after it the + * customer holds it in their own right and the guardian's authority is gone. Paying the guardian + * then is paying somebody with no standing, and paying the customer means paying somebody the + * bank has never identified. + * + * The account may still be a minor's on paper long after the birthday - the flag is only turned + * over by the nightly pass once KYC is verified - so age is read from the date of birth rather + * than trusted from the flag. + * + * Deliberately only on the way out. Collections continue, because money arriving carries none of + * this risk and a customer's saving should not stop over paperwork. + */ + private void refuseIfMajorWithoutKyc(final Party party) { + if (!MINOR.equalsIgnoreCase(party.getAdultMinor()) || party.getBirthDate() == null) { + return; + } + if (Period.between(party.getBirthDate(), DateUtils.getBusinessLocalDate()).getYears() < MAJORITY_AGE) { + return; + } + final Integer verified = this.jdbcTemplate.queryForObject(KYC_VERIFIED_SQL, Integer.class, party.clientId()); // NOSONAR + if (verified == null || verified == 0) { + throw new PartyMajorWithoutKycException(party.getPartyCode()); + } + } + + /** + * Refuses to pay out a deposit that is standing as security for a loan. + * + * A customer who needs money before the term ends can borrow against the deposit rather than + * break it - the scheme carries on collecting, and what was lent is held on the account as a + * lien. Paying the deposit out while that stands would hand over the bank's own security and + * leave the loan with nothing behind it. + * + * Both kinds of hold count. Fineract keeps a lien separately from the hold a loan or guarantee + * places, and either one means somebody else has a claim on this money. + * + * The account is not closed or refused anything else - the term has genuinely ended and the money + * is genuinely owed. It simply waits for the borrowing against it to be cleared. + */ + private void refuseIfHeldUnderLien(final Party party) { + // The lien the branch marks on the account itself. Checked first and without an amount: DDS + // pays a deposit out whole or not at all, so a hold on part of one would hold nothing the + // customer could otherwise have reached - the flag holds the account, and the reason beside + // it says what for. + if (party.isLien()) { + throw new PartyHeldUnderLienException(party.getPartyCode(), party.getLienReason()); + } + + // A hold placed on the savings account from outside DDS - by the savings screen, or by + // anything else in the bank that can freeze funds. DDS does not put these on, but it is + // still the same money and still cannot leave. A DDS account with no savings account behind + // it has nowhere for such a hold to be, and the flag above is its whole lien. + if (party.getSavingsAccountId() == null) { + return; + } + final SavingsAccount account = this.savingsAccountRepository.findById(party.getSavingsAccountId()).orElse(null); + if (account == null) { + return; + } + final BigDecimal held = account.getOnHoldFunds().add(account.getSavingsHoldAmount()); + if (held.compareTo(BigDecimal.ZERO) > 0) { + throw new PartyHeldUnderLienException(party.getPartyCode(), held); + } + } + + /** Everything needed to say whether the maturity proceeds can land in an account, and if not, why. */ + private static final String CREDIT_ACCOUNT_SQL = "select a.id, a.client_id, a.status_enum, a.product_id, p.deposit_type_enum " + + "from m_savings_account a join m_savings_product p on p.id = a.product_id where a.account_no = ?"; + + private static final int SAVINGS_DEPOSIT_TYPE = 100; + private static final int FIXED_DEPOSIT_TYPE = 200; + + /** + * The only status a maturity can be paid into. + * + * Not 303 - that is TRANSFER_IN_PROGRESS, not dormancy, which Fineract keeps as a sub-status. + * An account mid-transfer, still awaiting approval, or approved but never activated takes no + * credit, and one accepted here would fail on the single day it mattered. + */ + private static final int ACTIVE_SAVINGS_STATUS = 300; + + /** + * Refuses a maturity credit account the proceeds could not actually reach. + * + * Adv Code "Transfer To Saving" means the whole matured amount lands in this account. It was free + * text until this ran, and the live data showed what that costs: one party carried + * "1234567890111", which is no account at all - so its maturity would have had nowhere to go, and + * nobody would have found out until the day it matured. + * + * The account must belong to the same customer. That is what a maturity instruction is in every + * core system: paying a deposit into somebody else's account is a third-party payment, which + * banks take a written mandate for and handle separately. If this bank does allow it, this is the + * single check to relax - the other four stand either way. + * + * Judged on the account number alone, and no longer on the Adv Code beside it. It used to return + * early unless that code arrived saying "Transfer To Saving", which held for the screen - it + * sends both together - and not for anything else: an update carrying only the account number + * skipped the check entirely, and a number belonging to a fixed deposit, to a DDS scheme, or to + * another customer altogether went straight into the field. The number was refused in the end, + * on the day the account matured, by which time the instruction had looked settled for months. + * An account number that cannot receive the money is wrong to store whatever the code says. + */ + private void validateCreditToAccount(final Long ownerClientId, final JsonCommand command) { + if (!command.parameterExists("creditToAccount")) { + return; + } + final String accountNo = command.stringValueOfParameterNamedAllowingNull("creditToAccount"); + if (accountNo == null || accountNo.isBlank()) { + return; + } + payableAccountId(ownerClientId, accountNo); + } + + /** + * The id of an account the customer's maturity proceeds may be paid into, or the reason it may + * not be. + * + * The same five checks whether the account is being named years ahead as a maturity instruction + * or chosen on the day it is paid - so the answer cannot differ between the two, which is exactly + * the kind of gap that lets a payout fail on the one day it matters. + */ + private PayableAccount payableAccountId(final Long ownerClientId, final String accountNo) { + final List> rows = this.jdbcTemplate.queryForList(CREDIT_ACCOUNT_SQL, accountNo); // NOSONAR + if (rows.isEmpty()) { + // Not a savings account - a current account, then, which keeps its own table. The same + // checks as far as they apply to one: open, and the customer's own. It is never a DDS + // scheme account or a term deposit, so those two do not arise. + final List> current = this.jdbcTemplate.queryForList(CURRENT_ACCOUNT_SQL, accountNo); // NOSONAR + if (current.isEmpty()) { + throw new PartyCreditAccountUnusableException(accountNo, "no account with this number exists"); + } + final Map account = current.get(0); + if (((Number) account.get("status_enum")).intValue() != ACTIVE_CURRENT_STATUS) { + throw new PartyCreditAccountUnusableException(accountNo, "it is not open"); + } + final Object ownerId = account.get("client_id"); + if (ownerId == null || !ownerClientId.equals(((Number) ownerId).longValue())) { + throw new PartyCreditAccountUnusableException(accountNo, "it belongs to a different customer"); + } + return new PayableAccount(true, ((Number) account.get("id")).longValue()); + } + + final Map account = rows.get(0); + final int depositType = ((Number) account.get("deposit_type_enum")).intValue(); + if (depositType != SAVINGS_DEPOSIT_TYPE) { + throw new PartyCreditAccountUnusableException(accountNo, + "it is a " + (depositType == FIXED_DEPOSIT_TYPE ? "fixed" : "recurring") + " deposit, which cannot take the credit"); + } + + final int status = ((Number) account.get("status_enum")).intValue(); + if (status != ACTIVE_SAVINGS_STATUS) { + throw new PartyCreditAccountUnusableException(accountNo, "it is not open"); + } + + final Object ownerId = account.get("client_id"); + if (ownerId == null || !ownerClientId.equals(((Number) ownerId).longValue())) { + throw new PartyCreditAccountUnusableException(accountNo, "it belongs to a different customer"); + } + + // A DDS scheme account is a savings account too, so the checks above let it through - but it + // is another scheme of the customer's, collected on daily and paid out at its own maturity. + // Sending one maturity into another moves money between two collection accounts instead of + // handing it back, and if that scheme has already closed the money is stranded in an account + // nobody can operate. The same refusal an agent's commission account gets. + final Long productId = ((Number) account.get("product_id")).longValue(); + final List ddsProduct = this.jdbcTemplate.queryForList(DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR + if (!ddsProduct.isEmpty() && productId.equals(ddsProduct.get(0))) { + throw new PartyCreditAccountUnusableException(accountNo, "it is a DDS scheme account, not an account to be paid into"); + } + + return new PayableAccount(false, ((Number) account.get("id")).longValue()); + } + + /** An account a maturity can be paid into: a savings account, or a current account, by id. */ + private record PayableAccount(boolean current, Long id) {} + + private static final String CURRENT_ACCOUNT_SQL = "select a.id, a.client_id, a.status_enum from m_current_account a where a.account_no = ?"; + + /** Active, on a current account - the same number as on a savings account, in its own column. */ + private static final int ACTIVE_CURRENT_STATUS = 300; + + /** Pays the balance into the customer's own account, whichever kind it is. */ + private void payOutToOwn(final Party party, final PayableAccount to, final LocalDate date, final BigDecimal amount, + final String description) { + if (to.current()) { + this.ddsLedger.payOutToCurrentAccount(party, to.id(), date, amount, description); + } else { + this.ddsLedger.payOutToOwnAccount(party, to.id(), date, amount, description); + } + } + + /** Set by 0492. Names the savings product a DDS customer's real account is opened under. */ + private static final String DEPOSIT_PRODUCT_SQL = "select value from c_configuration " + + "where name = 'dds-deposit-product' and enabled = true"; + + /** + * Opens the customer's real deposit account, unless one was named and linked above. + * + * A DDS party used to be a row with a balance on it and nothing behind it: the customer's money + * existed inside DDS and nowhere the bank's books could see. Opening an account here is what makes + * the balance real - collections, interest and the eventual payout all land on it from now on. + * + * Opened active in one step rather than left pending: the customer has handed money to an agent + * by the time this runs, so an account that still needs approving would have nowhere to put it. + */ + private void openDepositAccountIfUnlinked(final Party party, final Client client) { + if (party.getSavingsAccountId() != null) { + return; + } + + final List productIds = this.jdbcTemplate.queryForList(DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR + if (productIds.isEmpty() || productIds.get(0) == null) { + throw new DdsDepositProductNotConfiguredException(); + } + + final SavingsAccountDataDTO accountDetails = new SavingsAccountDataDTO(client, null, productIds.get(0), party.getOpeningDate(), + this.context.authenticatedUser(), DateUtils.DEFAULT_DATE_FORMATTER); + final CommandProcessingResult result = this.savingsApplicationProcessWritePlatformService.createActiveApplication(accountDetails); + final Long savingsAccountId = result.getSavingsId() != null ? result.getSavingsId() : result.getResourceId(); + party.linkSavingsAccount(savingsAccountId); + + // Withdrawals are blocked from the moment the account exists. A scheme account is a savings + // account like any other, and left open a customer could empty a twelve-month scheme at the + // counter the next day - which is the one thing daily deposit saving exists to prevent. + // Deposits are untouched; only the way out is closed, and DDS lifts it for its own payouts. + this.ddsLedger.lockDebits(savingsAccountId); + } + + /** + * The branch an account belongs to, by its id. + * + * Asked for rather than taken from the collecting agent: the two are genuinely different, and + * an account silently inheriting its agent's branch is how a customer of one branch ends up + * counted at another. Refuses an id that names no branch rather than leaving the account + * unplaced. + */ + private Office officeNamed(final Long officeId) { + return this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId); + } + + private void addNomineesFromCommand(final Party party, final JsonCommand command) { + if (!command.parameterExists("nominees")) { + return; + } + final JsonArray nomineesArray = command.arrayOfParameterNamed("nominees"); + if (nomineesArray == null) { + return; + } + + // Read the way the rest of CBS reads a date inside a submitted array - through the request's + // own dateFormat and locale, taken once from the top level. The alternative, parsing the + // string directly, quietly requires ISO of this one field while every other date in the same + // request honours the stated format, and a request that states one is met with a 500 rather + // than a validation error naming the field. + final String dateFormat = command.dateFormat(); + final Locale locale = command.extractLocale(); + + for (int i = 0; i < nomineesArray.size(); i++) { + final JsonObject nomineeJson = nomineesArray.get(i).getAsJsonObject(); + final LocalDate nominationDate = this.fromApiJsonHelper.extractLocalDateNamed("nominationDate", nomineeJson, dateFormat, + locale); + final String name = nomineeJson.get("name").getAsString(); + final String mobileNo = nomineeJson.has("mobileNo") && !nomineeJson.get("mobileNo").isJsonNull() + ? nomineeJson.get("mobileNo").getAsString() + : null; + final LocalDate birthDate = this.fromApiJsonHelper.extractLocalDateNamed("birthDate", nomineeJson, dateFormat, locale); + // "minor" is not read from the request. It follows from the date of birth, and the two + // arriving separately is how a ten-year-old came to be recorded as not a minor. + final String relation = nomineeJson.get("relation").getAsString(); + final String gender = nomineeJson.get("gender").getAsString(); + final String guardianName = nomineeJson.has("guardianName") && !nomineeJson.get("guardianName").isJsonNull() + ? nomineeJson.get("guardianName").getAsString() + : null; + // Each is left null under the split it does not belong to rather than defaulted, so the + // record says "not stated" instead of "entitled to nothing" or "paid first". + final BigDecimal sharePercent = this.fromApiJsonHelper.extractBigDecimalNamed("sharePercent", nomineeJson, locale); + final Integer sequenceNo = this.fromApiJsonHelper.extractIntegerNamed("sequenceNo", nomineeJson, locale); + + party.addNominee(new PartyNominee(nominationDate, name, mobileNo, birthDate, relation, gender, guardianName, sharePercent, + sequenceNo)); + + // Flushed one at a time so the ids run in the order the clerk entered the names. The + // nominees are read back ordered by id, and a single flush at the end lets Hibernate + // insert them in whatever order it likes - which on a Sequence split would silently + // reverse who the bank pays first, the one thing that mode exists to record. + this.partyRepository.saveAndFlush(party); + } + } + + /** + * The Party screen's Sub Type is picked from the DDS Account Type master, so the code has to name + * one that exists and is still open for new business. Checked here rather than in the deserializer + * because it is a lookup against another table, not a shape check on the request. + */ + @Transactional + @Override + public CommandProcessingResult prematureClose(final Long partyId, final JsonCommand command) { + this.fromApiJsonDeserializer.validateForPrematureClose(command.json(), false); + return this.ddsLedger.withVoucher(DdsVoucherService.TYPE_PREMATURE, DdsVoucherService.REF_PARTY, partyId, + () -> closeEarly(partyId, command, null)); + } + + /** + * Closing early inside the scheme's lock-in, for this one account, with a reason - on its own + * permission, so it reaches a manager for approval rather than being settled by whoever keys it. + * + * Everything else is the ordinary early closure: the charge still falls on the customer or the + * agent, and the account must still be unmatured, unpledged and KYC-verified. Only the lock-in is + * waived, and the scheme's own lock-in is left as it is for every other account on it. + */ + @Transactional + @Override + public CommandProcessingResult prematureCloseWithinLockin(final Long partyId, final JsonCommand command) { + this.fromApiJsonDeserializer.validateForPrematureClose(command.json(), true); + final String reason = command.stringValueOfParameterNamed(LOCKIN_OVERRIDE_REASON).trim(); + return this.ddsLedger.withVoucher(DdsVoucherService.TYPE_PREMATURE, DdsVoucherService.REF_PARTY, partyId, + () -> closeEarly(partyId, command, reason)); + } + + private CommandProcessingResult closeEarly(final Long partyId, final JsonCommand command, final String lockinOverrideReason) { + final Party party = this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId)); + + if (party.isClosed()) { + throw new PartyAlreadyClosedException(party.getPartyCode()); + } + // An account that has reached its term is not closed early - it is paid out on its own terms, + // and charging it for closing early would take money the customer has fully earned. + if (party.getMaturityDate() != null && !party.getMaturityDate().isAfter(DateUtils.getBusinessLocalDate())) { + throw new PartyAlreadyMaturedException(party.getPartyCode(), party.getMaturityDate()); + } + refuseIfMajorWithoutKyc(party); + refuseIfHeldUnderLien(party); + + final DdsAccountType scheme = schemeOf(party); + if (lockinOverrideReason == null) { + refuseIfWithinLockin(party, scheme); + } + + // Taken from the request, the same way an ordinary close takes it, so both ways of closing an + // account agree on where the date comes from and on the format it arrives in. + final LocalDate closeDate = command.localDateValueOfParameterNamed("closeDate"); + + // The interest has to be brought up to date by the branch before the account is closed early: + // calculated and posted through the closing day, so the passbook shows what was earned before + // what is given back. Closing is refused until then, and says which runs are missing. + final BigDecimal interestOwed = this.batchRunWritePlatformService.interestNotYetPosted(party, closeDate); + if (interestOwed.compareTo(BigDecimal.ZERO) > 0) { + final boolean calculatedOnly = party.accruedInterest(false) != null + && party.accruedInterest(false).compareTo(interestOwed) == 0; + // A rule the request does not meet, not a permission it lacks - so a validation error. + final String message = "Interest of ₹" + interestOwed.setScale(2, RoundingMode.HALF_UP) + " on " + party.getPartyCode() + + " is not posted yet. " + + (calculatedOnly ? "Run Interest Posting" : "Run Interest Calculation and then Interest Posting") + " for " + + closeDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy")) + ", then close the account."; + throw new PlatformApiDataValidationException("error.msg.dds.party.interest.not.posted", message, + List.of(ApiParameterError.generalError("error.msg.dds.party.interest.not.posted", message))); + } + + // Nothing is left to credit by now; kept so a rounding remainder is never lost. + this.batchRunWritePlatformService.postInterestThrough(party, closeDate); + + final boolean customerBears = CUSTOMER_BEARS.equals(command.stringValueOfParameterNamed(SHORTFALL_BORNE_BY)); + final BigDecimal borneAmount = customerBears + ? recoverInterestFromCustomer(party, scheme, closeDate) + : topUpInterestFromAgent(party, closeDate); + + final BigDecimal commissionPenalty = chargeAgentCommission(party, scheme); + + payOutRemainder(party, command, closeDate, lockinOverrideReason); + + party.close(closeDate); + this.partyRepository.saveAndFlush(party); + + final Map changes = new HashMap<>(); + changes.put(SHORTFALL_BORNE_BY, customerBears ? CUSTOMER_BEARS : AGENT_BEARS); + changes.put("shortfallAmount", borneAmount); + changes.put("agentCommissionPenalty", commissionPenalty); + if (lockinOverrideReason != null) { + changes.put(LOCKIN_OVERRIDE_REASON, lockinOverrideReason); + } + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(partyId) // + .with(changes) // + .build(); + } + + /** The scheme an account runs on, which carries the terms for closing it early. */ + private DdsAccountType schemeOf(final Party party) { + return this.ddsAccountTypeRepository.findByAccountTypeCode(party.getSubType()) + .orElseThrow(() -> new PartySubTypeNotActiveException(party.getSubType(), false)); + } + + /** + * Below its scheme's lock-in an account is not closed early on any terms, so this refuses rather + * than charges. A scheme may set no lock-in at all, and then this never bites. + */ + private void refuseIfWithinLockin(final Party party, final DdsAccountType scheme) { + final Integer lockinMonths = scheme.getPrematureLockinMonths(); + if (lockinMonths == null || lockinMonths <= 0 || party.getOpeningDate() == null) { + return; + } + final LocalDate closableFrom = party.getOpeningDate().plusMonths(lockinMonths); + if (DateUtils.getBusinessLocalDate().isBefore(closableFrom)) { + throw new PartyWithinLockinPeriodException(party.getPartyCode(), lockinMonths, party.getOpeningDate(), closableFrom); + } + } + + /** + * The customer carries it: their interest is recalculated at a lower rate and the difference taken + * back. + * + * The reduction applies to the whole period, not only the part left to run - a scheme paying 2% + * pays 1% from the day the account opened. Because simple interest is proportional to the rate, + * what has to come back is the interest already credited scaled by how much of the rate is being + * given up, which needs no re-run of the daily products. + * + * A reduction at or above the scheme's own rate leaves no interest at all rather than turning + * negative and charging the customer for having saved. + */ + private BigDecimal recoverInterestFromCustomer(final Party party, final DdsAccountType scheme, final LocalDate closeDate) { + final BigDecimal rate = party.getInterestRate(); + final BigDecimal reduction = scheme.getPrematureInterestReduction(); + if (rate == null || rate.compareTo(BigDecimal.ZERO) <= 0 || reduction == null || reduction.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } + + final BigDecimal creditedSoFar = interestCreditedTo(party); + if (creditedSoFar.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } + + final BigDecimal givenUpShare = reduction.min(rate).divide(rate, MathContext.DECIMAL64); + final BigDecimal recovered = creditedSoFar.multiply(givenUpShare).setScale(MONEY_SCALE, RoundingMode.HALF_UP); + if (recovered.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } + + final String description = "DDS premature closure interest recovery for " + party.getPartyCode(); + this.ddsLedger.reverseInterest(party, closeDate, recovered, description); + final BigDecimal balanceAfter = party.debit(recovered); + this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), closeDate, PREMATURE_INTEREST_RECOVERY, + DIRECTION_DEBIT, null, null, null, recovered, null, null, + "Interest recalculated at " + rate.subtract(reduction.min(rate)) + "% on early closure", balanceAfter)); + return recovered; + } + + /** + * The agent carries it: the customer keeps a full month's interest and the agent funds the days + * they did not save for. + * + * A collection round that stops mid-month costs the bank a month it had planned on. Rather than + * shorten the customer, the agent makes up the rest of that month - which is the month they were + * meant to keep collecting through. + */ + private BigDecimal topUpInterestFromAgent(final Party party, final LocalDate closeDate) { + final BigDecimal rate = party.getInterestRate(); + final BigDecimal balance = party.getCurrentBalance(); + if (rate == null || rate.compareTo(BigDecimal.ZERO) <= 0 || balance == null || balance.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } + + final int remainingDays = closeDate.lengthOfMonth() - closeDate.getDayOfMonth(); + if (remainingDays <= 0) { + // Closed on the last day of the month - there is nothing left of it to fund. + return BigDecimal.ZERO; + } + + final BigDecimal topUp = DdsInterestCalculation.interestOn(balance.multiply(BigDecimal.valueOf(remainingDays)), rate); + if (topUp.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } + + final String description = "DDS premature closure interest funded by agent for " + party.getPartyCode(); + this.ddsLedger.postInterest(party, closeDate, topUp, description); + final BigDecimal balanceAfter = party.credit(topUp); + this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), closeDate, PREMATURE_AGENT_TOP_UP, + DIRECTION_CREDIT, null, null, null, topUp, null, null, + remainingDays + " days of " + closeDate.getMonth() + " funded by " + party.getAgent().getAgentCode(), balanceAfter)); + + party.getAgent().chargePrematureClosure(topUp); + this.agentRepository.saveAndFlush(party.getAgent()); + return topUp; + } + + /** + * The agent's share, charged whichever way the interest was settled: an account closed early is a + * round they did not keep going. + * + * Taken on what they are owed now rather than on this account's own collections, because + * commission is earned per agent over a period and was never broken down per account - there is + * no per-account figure to take a percentage of. + */ + private BigDecimal chargeAgentCommission(final Party party, final DdsAccountType scheme) { + final BigDecimal penaltyRate = scheme.getPrematureAgentCommissionPenalty(); + final Agent agent = party.getAgent(); + if (penaltyRate == null || penaltyRate.compareTo(BigDecimal.ZERO) <= 0 || agent == null + || agent.getCommission().compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } + final BigDecimal penalty = agent.getCommission().multiply(penaltyRate).divide(HUNDRED, MONEY_SCALE, RoundingMode.HALF_UP); + if (penalty.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } + agent.chargePrematureClosure(penalty); + this.agentRepository.saveAndFlush(agent); + return penalty; + } + + /** + * What is left after the charge goes out the same way a matured payout does - to another account + * of the customer's, or into matured-deposits payable when they have none. + */ + private void payOutRemainder(final Party party, final JsonCommand command, final LocalDate closeDate, + final String lockinOverrideReason) { + final BigDecimal amount = party.getCurrentBalance(); + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + return; + } + + final String description = "DDS premature closure payout for " + party.getPartyCode(); + final String toAccountNo = command.stringValueOfParameterNamedAllowingNull("toSavingsAccountNo"); + final boolean toOwnAccount = toAccountNo != null && !toAccountNo.isBlank(); + GLAccount parkIn = null; + if (toOwnAccount) { + final PayableAccount toAccount = payableAccountId(party.clientId(), toAccountNo); + payOutToOwn(party, toAccount, closeDate, amount, description); + } else { + parkIn = this.ddsLedger.sundryAccount(command.longValueOfParameterNamed("sundryGlAccountId")); + this.ddsLedger.payOutToPayable(party, closeDate, amount, description, parkIn); + } + + // A lock-in waived for this account says so on its passbook line, with the reason given. + String note = toOwnAccount ? "Early closure paid into " + toAccountNo : "Early closure parked in Sundry " + parkedIn(parkIn); + if (lockinOverrideReason != null) { + note = note + " - within lock-in, approved: " + lockinOverrideReason; + } + final BigDecimal balanceAfter = party.debit(amount); + this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), closeDate, PREMATURE_PAYOUT, DIRECTION_DEBIT, + null, null, toOwnAccount ? toAccountNo : null, amount, null, null, note, balanceAfter)); + } + + /** What this account has actually been credited in interest, which is what a reduction acts on. */ + private BigDecimal interestCreditedTo(final Party party) { + final BigDecimal credited = this.jdbcTemplate.queryForObject(INTEREST_CREDITED_SQL, BigDecimal.class, party.getId()); // NOSONAR + return credited == null ? BigDecimal.ZERO : credited; + } + + private void validateSubType(final String subType) { + final DdsAccountType accountType = this.ddsAccountTypeRepository.findByAccountTypeCode(subType) + .orElseThrow(() -> new PartySubTypeNotActiveException(subType, false)); + if (!accountType.isActive()) { + throw new PartySubTypeNotActiveException(subType, true); + } + } + + private String generateNextPartyCode() { + final Long maxSuffix = this.partyRepository.retrieveMaxPartyCodeSuffix(); + final long nextSuffix = (maxSuffix == null ? 0L : maxSuffix) + 1L; + return String.format("PT%04d", nextSuffix); + } + + private void handleDataIntegrityIssues(final Throwable realCause, final Exception dve) { + if (realCause.getMessage() != null && realCause.getMessage().contains("party_code")) { + throw new PlatformDataIntegrityException("error.msg.party.duplicate.partyCode", + "A party with the generated code already exists, please retry"); + } + + log.error("Error occured.", dve); + throw ErrorHandler.getMappable(dve, "error.msg.party.unknown.data.integrity.issue", + "Unknown data integrity issue with resource: " + realCause.getMessage()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/starter/DdsPartyConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/starter/DdsPartyConfiguration.java new file mode 100644 index 000000000..aee030328 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/starter/DdsPartyConfiguration.java @@ -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.party.starter; + +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.agent.domain.AgentRepository; +import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.portfolio.dds.accounting.DdsLedger; +import org.apache.fineract.portfolio.dds.party.domain.PartyRepository; +import org.apache.fineract.portfolio.dds.party.serialization.PartyCommandFromApiJsonDeserializer; +import org.apache.fineract.portfolio.dds.party.service.PartyReadPlatformService; +import org.apache.fineract.portfolio.dds.party.service.PartyReadPlatformServiceImpl; +import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountTypeRepository; +import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService; +import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformServiceJpaRepositoryImpl; +import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService; +import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository; +import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService; +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 DdsPartyConfiguration { + + @Bean + @ConditionalOnMissingBean(PartyReadPlatformService.class) + public PartyReadPlatformService partyReadPlatformService(JdbcTemplate jdbcTemplate) { + return new PartyReadPlatformServiceImpl(jdbcTemplate); + } + + @Bean + @ConditionalOnMissingBean(PartyWritePlatformService.class) + public PartyWritePlatformService partyWritePlatformService(PartyCommandFromApiJsonDeserializer fromApiJsonDeserializer, + PartyRepository partyRepository, AgentRepository agentRepository, ClientRepositoryWrapper clientRepositoryWrapper, + SavingsAccountRepository savingsAccountRepository, DdsAccountTypeRepository ddsAccountTypeRepository, + SavingsApplicationProcessWritePlatformService savingsApplicationProcessWritePlatformService, + DdsLedger ddsLedger, PartyTransactionRepository partyTransactionRepository, + BatchRunWritePlatformService batchRunWritePlatformService, PlatformSecurityContext context, JdbcTemplate jdbcTemplate, + FromJsonHelper fromApiJsonHelper, OfficeRepositoryWrapper officeRepositoryWrapper) { + return new PartyWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, partyRepository, agentRepository, + clientRepositoryWrapper, savingsAccountRepository, ddsAccountTypeRepository, + savingsApplicationProcessWritePlatformService, ddsLedger, partyTransactionRepository, batchRunWritePlatformService, + context, jdbcTemplate, fromApiJsonHelper, officeRepositoryWrapper); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/report/api/DdsReportsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/report/api/DdsReportsApiResource.java new file mode 100644 index 000000000..900564dc8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/report/api/DdsReportsApiResource.java @@ -0,0 +1,110 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.report.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.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.Locale; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.dds.report.data.DdsReportData; +import org.apache.fineract.portfolio.dds.report.service.DdsReportReadPlatformService; +import org.springframework.stereotype.Component; + +@Path("/v1/dds/reports/{reportKey}") +@Component +@Tag(name = "DDS Reports", description = "The DDS reports suite - one shared query engine behind a generic column/row shape, filtered by date range and agent/party id range.") +@RequiredArgsConstructor +public class DdsReportsApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DDSREPORT"; + + private final PlatformSecurityContext context; + private final DdsReportReadPlatformService readPlatformService; + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Run a DDS Report", description = "reportKey is one of: AGENT_CHECK_LIST, PARTY_CHECK_LIST, PARTY_BALANCE, " + + "AGENT_WISE_BALANCE, AGENT_WISE_MONTHLY_COLLECTION, COMMISSION_REGISTER, COLLECTION_SUMMARY_AGENT_WISE, " + + "AGENT_PARTYWISE_DAILY_COLLECTION, DAILY_COLLECTION_REPORT, PARTY_INTEREST_REGISTER, BALANCE_SUMMARY, " + + "TRANSACTION_REGISTER, MATURE_DATE_WISE, INTEREST_CERTIFICATE, AGENT_COMMISSION_STATEMENT, SUPPLEMENTARY_DDS, " + + "CLOSE_PARTY_AC_REGISTER, MASTER_DATA_EXPORT (the last three always return available=false - no matching data exists " + + "in this system yet). " + + "fromDate/toDate are ISO (yyyy-MM-dd); agentFrom/To and partyFrom/To default to the full id range.") + public DdsReportData runReport(@PathParam("reportKey") @Parameter(description = "reportKey") final String reportKey, + @QueryParam("fromDate") @Parameter(description = "fromDate") final String fromDate, + @QueryParam("toDate") @Parameter(description = "toDate") final String toDate, + @DefaultValue("1") @QueryParam("agentFrom") @Parameter(description = "agentFrom") final Long agentFrom, + @DefaultValue("9223372036854775807") @QueryParam("agentTo") @Parameter(description = "agentTo") final Long agentTo, + @DefaultValue("1") @QueryParam("partyFrom") @Parameter(description = "partyFrom") final Long partyFrom, + @DefaultValue("9223372036854775807") @QueryParam("partyTo") @Parameter(description = "partyTo") final Long partyTo) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + + final LocalDate from = parseDate("fromDate", fromDate, LocalDate.of(1900, 1, 1)); + final LocalDate to = parseDate("toDate", toDate, LocalDate.of(9999, 12, 31)); + + // A range the wrong way round returns nothing, which reads as "no such data" rather than + // "the dates are swapped" - so it is refused instead. + if (from.isAfter(to)) { + throw badRequest("fromDate", "fromDate " + from + " is after toDate " + to + "."); + } + + return readPlatformService.runReport(reportKey, from, to, agentFrom, agentTo, partyFrom, partyTo); + } + + /** + * Parses a date parameter, or says which one was wrong and what it should look like. + * + * {@code LocalDate.parse} throws on anything but ISO, and an unhandled parse failure leaves the + * caller a bare 500 - which reads as a broken report rather than a malformed request. Every report + * shares these two parameters, so one bad date looked like all eighteen reports were down. + */ + private LocalDate parseDate(final String parameterName, final String value, final LocalDate absent) { + if (value == null || value.isBlank()) { + return absent; + } + try { + return LocalDate.parse(value); + } catch (final DateTimeParseException e) { + throw badRequest(parameterName, parameterName + " must be a date in yyyy-MM-dd form, not \"" + value + "\"."); + } + } + + private PlatformApiDataValidationException badRequest(final String parameterName, final String message) { + final ApiParameterError error = ApiParameterError.parameterError("validation.msg.ddsreport." + parameterName.toLowerCase(Locale.ROOT) + + ".invalid", message, parameterName); + return new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", + List.of(error)); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/report/service/DdsReportReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/report/service/DdsReportReadPlatformService.java new file mode 100644 index 000000000..bd46f29a8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/report/service/DdsReportReadPlatformService.java @@ -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.report.service; + +import java.time.LocalDate; +import org.apache.fineract.portfolio.dds.report.data.DdsReportData; + +public interface DdsReportReadPlatformService { + + DdsReportData runReport(String reportKey, LocalDate fromDate, LocalDate toDate, Long agentFrom, Long agentTo, Long partyFrom, + Long partyTo); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/report/service/DdsReportReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/report/service/DdsReportReadPlatformServiceImpl.java new file mode 100644 index 000000000..4f7797ba5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/report/service/DdsReportReadPlatformServiceImpl.java @@ -0,0 +1,534 @@ +/** + * 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.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException; +import org.apache.fineract.portfolio.dds.report.data.DdsReportData; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * One switch over every DDS report key. Each branch is a real query against data this system + * actually has (Agent/Party/PartyTransaction/CommissionSlab/BatchRun). + * + * Since SubAgent was folded into Agent (2026-08-24, Agent now does the daily collection work + * directly), the queries here dropped a join level wherever they used to go Party/Transaction -> + * SubAgent -> Agent - they now join straight to Agent. + */ +@RequiredArgsConstructor +public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformService { + + private final JdbcTemplate jdbcTemplate; + + /** + * The stored transaction type rendered as a person would say it. A statement that prints + * WITHDRAW_DEPOSITE reads as a database dump rather than a bank's record. + */ + private static final String TRANSACTION_TYPE_LABEL = "case t.transaction_type " + + "when 'PARTY_TRAN' then 'Collection' " + "when 'AGENT_TRAN' then 'Settlement' " + + "when 'WITHDRAW_DEPOSITE' then 'Transfer' " + "when 'INTEREST_POST' then 'Interest' " + + "when 'OD_INTEREST_POST' then 'OD Interest' " + "when 'COMMISSION_POST' then 'Commission Paid' " + + "when 'MATURITY_PAYOUT' then 'Maturity Payout' " + "when 'PREMATURE_PAYOUT' then 'Premature Payout' " + + "when 'PREMATURE_INT_RECOVERY' then 'Interest Recovered (Premature)' " + + "when 'PREMATURE_AGENT_TOP_UP' then 'Interest Funded by Agent' " + "else t.transaction_type end"; + + /** + * What an agent collected and has not yet handed in: everything they wrote down and is still + * unsettled, less what the cashier has already taken from them but no clerk has applied yet. + * The same figure the Agent Tran screen shows as still to take. It used to read a balance on the + * agent row that the cashier flow never moves, so it said nothing once cash came in at the counter. + */ + private static final String AGENT_CASH_IN_HAND = "greatest(coalesce((select sum(e.amount) from m_dds_collection_entry e " + + "where e.agent_id = a.id and e.status = 'PENDING'), 0) - coalesce((select sum(r.amount) " + + "from m_cashier_deposit_request r join m_code_value cv on cv.id = r.entry_type_cv_id " + + "join acc_gl_account g on g.id = a.gl_account_id " + + "where cv.code_value = 'DDS' and r.status = 'APPROVED' and r.account_no = g.gl_code " + + "and not exists (select 1 from m_dds_receipt_application_voucher v where v.cashier_deposit_request_id = r.id)), 0), 0)"; + + /** How an agent's standing is described wherever a list of agents is printed. */ + private static final String AGENT_STATUS_LABEL = "case when a.close_date is not null then 'Closed' " + + "when a.is_active then 'Active' else 'Not collecting' end"; + + @Override + public DdsReportData runReport(final String reportKey, final LocalDate fromDate, final LocalDate toDate, final Long agentFrom, + final Long agentTo, final Long partyFrom, final Long partyTo) { + + return switch (reportKey) { + case "AGENT_CHECK_LIST" -> agentCheckList(); + case "PARTY_CHECK_LIST" -> partyCheckList(agentFrom, agentTo, partyFrom, partyTo); + case "PARTY_BALANCE" -> partyBalance(agentFrom, agentTo, partyFrom, partyTo); + case "AGENT_WISE_BALANCE" -> agentWiseBalance(agentFrom, agentTo); + case "AGENT_WISE_MONTHLY_COLLECTION" -> agentWiseCollection(fromDate, toDate, agentFrom, agentTo); + case "COMMISSION_REGISTER" -> commissionRegister(fromDate, toDate, agentFrom, agentTo); + case "COLLECTION_SUMMARY_AGENT_WISE" -> collectionSummaryAgentWise(fromDate, toDate, agentFrom, agentTo); + case "AGENT_PARTYWISE_DAILY_COLLECTION" -> agentPartywiseDailyCollection(fromDate, toDate, agentFrom, agentTo, partyFrom, + partyTo); + case "DAILY_COLLECTION_REPORT" -> dailyCollectionReport(fromDate, toDate, agentFrom, agentTo); + case "PARTY_INTEREST_REGISTER" -> partyInterestRegister(fromDate, toDate, partyFrom, partyTo); + case "BALANCE_SUMMARY" -> balanceSummary(agentFrom, agentTo, partyFrom, partyTo); + case "TRANSACTION_REGISTER" -> transactionRegister(fromDate, toDate, agentFrom, agentTo, partyFrom, partyTo); + case "MATURE_DATE_WISE" -> matureDateWise(fromDate, toDate, agentFrom, agentTo, partyFrom, partyTo); + case "INTEREST_CERTIFICATE" -> interestCertificate(fromDate, toDate, agentFrom, agentTo, partyFrom, partyTo); + case "AGENT_COMMISSION_STATEMENT" -> agentCommissionStatement(fromDate, toDate); + case "SUPPLEMENTARY_DDS" -> supplementaryDds(fromDate, toDate, agentFrom, agentTo, partyFrom, partyTo); + case "CLOSE_PARTY_AC_REGISTER" -> closePartyAcRegister(fromDate, toDate, agentFrom, agentTo, partyFrom, partyTo); + case "MASTER_DATA_EXPORT" -> masterDataExport(agentFrom, agentTo, partyFrom, partyTo); + default -> throw new UnrecognizedQueryParamException("reportKey", reportKey); + }; + } + + /** + * Every agent on the books - the list a supervisor checks collection rounds against. + * + * Carries how many accounts each agent is responsible for and what the bank owes them, because + * those are the two figures that decide whether an agent can be given more work or has to be + * settled with first. Status matters as much: without it a closed agent and a working one read + * identically. The cash column is named for what it is - money collected from customers that has + * not reached a branch, not a deposit balance. + */ + private DdsReportData agentCheckList() { + final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", a.agent_city as \"City\", " + + AGENT_STATUS_LABEL + " as \"Status\", a.agent_opening_date as \"Opening Date\", " + + "(select count(*) from m_dds_party p where p.agent_id = a.id and p.status = 'ACTIVE') as \"Accounts\", " + + AGENT_CASH_IN_HAND + " as \"Cash In Hand\", a.commission as \"Commission Due\" " + + "from m_dds_agent a order by a.agent_code"; + final List> rows = this.jdbcTemplate.queryForList(sql); + return available("AGENT_CHECK_LIST", + List.of("Agent Code", "Agent Name", "City", "Status", "Opening Date", "Accounts", "Cash In Hand", "Commission Due"), + rows); + } + + /** + * Every DDS account with the terms it was opened on - the list used to check an account against + * its passbook, so it carries the scheme, the instalment and the maturity date rather than money. + */ + private DdsReportData partyCheckList(final Long agentFrom, final Long agentTo, final Long partyFrom, final Long partyTo) { + final String sql = "select p.party_code as \"Party Code\", p.party_name as \"Party Name\", " + + "a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", p.sub_type as \"Sub Type\", " + + "p.status as \"Status\", p.opening_date as \"Opening Date\", " + + "p.daily_deposit_amount as \"Daily Deposit\", p.maturity_date as \"Maturity Date\" " + + "from m_dds_party p join m_dds_agent a on a.id = p.agent_id " + + "where a.id between ? and ? and p.id between ? and ? order by p.party_code"; + final List> rows = this.jdbcTemplate.queryForList(sql, agentFrom, agentTo, partyFrom, partyTo); + return available("PARTY_CHECK_LIST", List.of("Party Code", "Party Name", "Agent Code", "Agent Name", "Sub Type", "Status", + "Opening Date", "Daily Deposit", "Maturity Date"), rows); + } + + /** + * What each account is worth right now - the money view, as against the terms view the check list + * gives. The two used to share one query and so were the same report twice. + * + * Interest already worked out but not yet credited is shown beside the balance and added into a + * total: the customer is owed both, and the balance alone understates what the bank holds for them. + */ + private DdsReportData partyBalance(final Long agentFrom, final Long agentTo, final Long partyFrom, final Long partyTo) { + final String sql = "select p.party_code as \"Party Code\", p.party_name as \"Party Name\", " + + "a.agent_code as \"Agent Code\", p.status as \"Status\", p.current_balance as \"Current Balance\", " + + "(p.interest_calc + p.od_interest_calc) as \"Interest Accrued\", " + + "(p.current_balance + p.interest_calc + p.od_interest_calc) as \"Total Due To Customer\" " + + "from m_dds_party p join m_dds_agent a on a.id = p.agent_id " + + "where a.id between ? and ? and p.id between ? and ? order by p.party_code"; + final List> rows = this.jdbcTemplate.queryForList(sql, agentFrom, agentTo, partyFrom, partyTo); + return available("PARTY_BALANCE", List.of("Party Code", "Party Name", "Agent Code", "Status", "Current Balance", + "Interest Accrued", "Total Due To Customer"), rows); + } + + /** What each agent is holding and what they are owed - the two figures a settlement turns on. */ + private DdsReportData agentWiseBalance(final Long agentFrom, final Long agentTo) { + final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", " + AGENT_STATUS_LABEL + + " as \"Status\", " + AGENT_CASH_IN_HAND + " as \"Cash In Hand\", a.commission as \"Commission Due\" " + + "from m_dds_agent a where a.id between ? and ? order by a.agent_code"; + final List> rows = this.jdbcTemplate.queryForList(sql, agentFrom, agentTo); + return available("AGENT_WISE_BALANCE", List.of("Agent Code", "Agent Name", "Status", "Cash In Hand", "Commission Due"), rows); + } + + /** + * How much each agent collected over the period, and how that total was made up. + * + * The count of entries and of distinct accounts is what separates a genuine round from a single + * large deposit: the same total can mean fifty daily instalments or one lump sum, and only the + * former says the agent worked their round. + */ + private DdsReportData agentWiseCollection(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom, + final Long agentTo) { + final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", " + + "count(distinct t.party_id) as \"Accounts Collected From\", count(t.id) as \"Entries\", " + + "coalesce(sum(t.amount), 0) as \"Total Collection\" from m_dds_agent a " + + "left join m_dds_party_transaction t on t.agent_id = a.id and t.transaction_type = 'PARTY_TRAN' " + + "and t.direction = 'CREDIT' and t.transaction_date between ? and ? " + "where a.id between ? and ? " + + "group by a.id, a.agent_code, a.agent_name order by a.agent_code"; + final List> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo); + return available("AGENT_WISE_MONTHLY_COLLECTION", + List.of("Agent Code", "Agent Name", "Accounts Collected From", "Entries", "Total Collection"), rows); + } + + /** + * How each agent's commission was arrived at, not merely what it came to. + * + * The register used to print the net figure and two dates, which answers "how much" but leaves an + * agent querying their payment with nowhere to look. The commission run already records the + * collection it was calculated on, the rate it found in the chart, and each deduction - so the + * register shows the whole line of reasoning, and a disputed figure can be checked without going + * back to the batch run. + */ + private DdsReportData commissionRegister(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom, + final Long agentTo) { + final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", " + + "r.from_date as \"Period From\", r.to_date as \"Period To\", r.base_amount as \"Collection\", " + + "r.rate_percent as \"Rate %\", r.gross_amount as \"Gross Commission\", r.tds_amount as \"TDS\", " + + "r.deduction_amount as \"Deductions\", r.amount as \"Net Commission\", b.posted_on_date as \"Posted On\" " + + "from m_dds_batch_run_row r " + + "join m_dds_batch_run b on b.id = r.batch_run_id join m_dds_agent a on a.id = r.agent_id " + + "where b.job_type = 'COMMISSION_CALCULATION' and b.status = 'POSTED' and b.posted_on_date between ? and ? " + + "and a.id between ? and ? order by b.posted_on_date, a.agent_code"; + final List> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo); + return available("COMMISSION_REGISTER", List.of("Agent Code", "Agent Name", "Period From", "Period To", "Collection", + "Rate %", "Gross Commission", "TDS", "Deductions", "Net Commission", "Posted On"), rows); + } + + /** + * The settlement view of a period: what each agent collected, what of it reached a branch, what + * they are still holding, and what they earned on it. + * + * This used to run exactly the same query as the agent-wise collection report above, so the two + * menu entries produced identical tables. The difference is the question each answers: that one + * asks how the collection was made up, this one asks whether the money came in and what it cost. + */ + private DdsReportData collectionSummaryAgentWise(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom, + final Long agentTo) { + // Collected is what the agent wrote down over the period; settled is the part of it the branch + // has received and credited to customers. Both come from the agent's own entries, since a + // collection reaches the customer's passbook only once it is settled - reading passbooks alone + // counted only what was settled and called it collected. + final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", " + + "coalesce((select sum(e.amount) from m_dds_collection_entry e where e.agent_id = a.id " + + "and e.status in ('PENDING', 'SETTLED') and e.entry_date between ? and ?), 0) as \"Collected\", " + + "coalesce((select sum(e.amount) from m_dds_collection_entry e where e.agent_id = a.id " + + "and e.status = 'SETTLED' and e.entry_date between ? and ?), 0) as \"Settled To Branch\", " + + AGENT_CASH_IN_HAND + " as \"Cash In Hand\", " + + "coalesce((select sum(t.amount) from m_dds_party_transaction t where t.agent_id = a.id " + + "and t.transaction_type = 'COMMISSION_POST' and t.transaction_date between ? and ?), 0) as \"Commission Paid\" " + + "from m_dds_agent a where a.id between ? and ? order by a.agent_code"; + final List> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, fromDate, toDate, fromDate, toDate, + agentFrom, agentTo); + return available("COLLECTION_SUMMARY_AGENT_WISE", + List.of("Agent Code", "Agent Name", "Collected", "Settled To Branch", "Cash In Hand", "Commission Paid"), rows); + } + + private DdsReportData agentPartywiseDailyCollection(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom, + final Long agentTo, final Long partyFrom, final Long partyTo) { + // Both sides named, not just coded - this is the sheet a collector's round is checked against, + // and a column of codes alone cannot be read against a passbook. + final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", " + + "p.party_code as \"Party Code\", p.party_name as \"Party Name\", " + + "t.transaction_date as \"Transaction Date\", t.transaction_mode as \"Mode\", t.amount as \"Amount\", " + + "t.balance_after_transaction as \"Balance After\" from m_dds_party_transaction t " + + "join m_dds_party p on p.id = t.party_id join m_dds_agent a on a.id = t.agent_id " + + "where t.transaction_type = 'PARTY_TRAN' and t.direction = 'CREDIT' and t.transaction_date between ? and ? " + + "and a.id between ? and ? and p.id between ? and ? order by t.transaction_date, a.agent_code, p.party_code"; + final List> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom, + partyTo); + return available("AGENT_PARTYWISE_DAILY_COLLECTION", List.of("Agent Code", "Agent Name", "Party Code", "Party Name", + "Transaction Date", "Mode", "Amount", "Balance After"), rows); + } + + private DdsReportData dailyCollectionReport(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom, + final Long agentTo) { + // Collected against settled, day by day: the difference is what the agent went home holding, + // which is the figure a day-end check is actually looking for. + // Each day's collection from the agent's own entries, and how much of that day's collection + // the branch has since received and credited - the rest is still with the agent. + final String sql = "select e.entry_date as \"Transaction Date\", a.agent_code as \"Agent Code\", " + + "a.agent_name as \"Agent Name\", count(*) as \"Entries\", coalesce(sum(e.amount), 0) as \"Collected\", " + + "coalesce(sum(e.amount) filter (where e.status = 'SETTLED'), 0) as \"Settled To Branch\" " + + "from m_dds_collection_entry e join m_dds_agent a on a.id = e.agent_id " + + "where e.status in ('PENDING', 'SETTLED') and e.entry_date between ? and ? " + + "and a.id between ? and ? group by e.entry_date, a.id, a.agent_code, a.agent_name " + + "order by e.entry_date, a.agent_code"; + final List> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo); + return available("DAILY_COLLECTION_REPORT", + List.of("Transaction Date", "Agent Code", "Agent Name", "Entries", "Collected", "Settled To Branch"), rows); + } + + private DdsReportData partyInterestRegister(final LocalDate fromDate, final LocalDate toDate, final Long partyFrom, + final Long partyTo) { + final String sql = "select p.party_code as \"Party Code\", p.party_name as \"Party Name\", " + + "a.agent_code as \"Agent Code\", t.transaction_date as \"Posted On\", " + TRANSACTION_TYPE_LABEL + + " as \"Interest Type\", case when t.direction = 'DEBIT' then -t.amount else t.amount end as \"Interest\", " + + "t.balance_after_transaction as \"Balance After\" " + + "from m_dds_party_transaction t join m_dds_party p on p.id = t.party_id " + + "join m_dds_agent a on a.id = t.agent_id " + // Interest given back on an early closure is part of the interest story, shown negative. + + "where t.transaction_type in ('INTEREST_POST', 'OD_INTEREST_POST', 'PREMATURE_AGENT_TOP_UP', " + + "'PREMATURE_INT_RECOVERY') and t.transaction_date between ? and ? " + + "and p.id between ? and ? order by t.transaction_date, p.party_code"; + final List> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, partyFrom, partyTo); + return available("PARTY_INTEREST_REGISTER", + List.of("Party Code", "Party Name", "Agent Code", "Posted On", "Interest Type", "Interest", "Balance After"), rows); + } + + /** + * The whole scheme on one line: how many are on the books, how many are working, and where the + * money is sitting. + * + * The counts are split active from total because the two answer different questions - how big the + * scheme is, and how much of it is live. And the money is split three ways because it genuinely + * sits in three places: in customers' balances, in interest worked out but not yet credited, and + * in cash the agents have collected but not handed over. A single "total balance" hides the last + * two, and it is the third that a branch most wants to see. + */ + private DdsReportData balanceSummary(final Long agentFrom, final Long agentTo, final Long partyFrom, final Long partyTo) { + final String agentSql = "select count(*) as \"Total Agents\", " + + "count(*) filter (where is_active and close_date is null) as \"Active Agents\", " + + "coalesce(sum(" + AGENT_CASH_IN_HAND + "), 0) as \"Cash With Agents\", " + + "coalesce(sum(commission), 0) as \"Commission Due To Agents\" from m_dds_agent a where a.id between ? and ?"; + final Map agentTotals = this.jdbcTemplate.queryForMap(agentSql, agentFrom, agentTo); + + final String partySql = "select count(*) as \"Total Accounts\", " + + "count(*) filter (where status = 'ACTIVE') as \"Active Accounts\", " + + "coalesce(sum(current_balance), 0) as \"Deposits Held\", " + + "coalesce(sum(interest_calc + od_interest_calc), 0) as \"Interest Accrued\" " + + "from m_dds_party where agent_id between ? and ? and id between ? and ?"; + final Map partyTotals = this.jdbcTemplate.queryForMap(partySql, agentFrom, agentTo, partyFrom, partyTo); + + final Map row = new LinkedHashMap<>(); + row.put("Total Agents", agentTotals.get("Total Agents")); + row.put("Active Agents", agentTotals.get("Active Agents")); + row.put("Total Accounts", partyTotals.get("Total Accounts")); + row.put("Active Accounts", partyTotals.get("Active Accounts")); + row.put("Deposits Held", partyTotals.get("Deposits Held")); + row.put("Interest Accrued", partyTotals.get("Interest Accrued")); + row.put("Cash With Agents", agentTotals.get("Cash With Agents")); + row.put("Commission Due To Agents", agentTotals.get("Commission Due To Agents")); + + return available("BALANCE_SUMMARY", List.of("Total Agents", "Active Agents", "Total Accounts", "Active Accounts", + "Deposits Held", "Interest Accrued", "Cash With Agents", "Commission Due To Agents"), List.of(row)); + } + + private DdsReportData transactionRegister(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom, + final Long agentTo, final Long partyFrom, final Long partyTo) { + // Left join, and a party filter that lets nulls through: an agent's settlement and a + // commission payment carry no party, so an inner join silently dropped them - a register + // missing whole classes of entry is worse than no register, because it looks complete. + // + // Debit and credit in their own columns rather than an amount beside a direction word: that + // is how a ledger is read, and it lets each side be totalled. + final String sql = "select t.transaction_date as \"Date\", " + TRANSACTION_TYPE_LABEL + " as \"Type\", " + + "p.party_code as \"Party Code\", p.party_name as \"Party Name\", " + + "a.agent_code as \"Agent Code\", t.transaction_mode as \"Mode\", " + + "case when t.direction = 'DEBIT' then t.amount end as \"Debit\", " + + "case when t.direction = 'CREDIT' then t.amount end as \"Credit\", " + + "t.balance_after_transaction as \"Balance After\", t.description as \"Description\" " + + "from m_dds_party_transaction t left join m_dds_party p on p.id = t.party_id " + + "join m_dds_agent a on a.id = t.agent_id " + + "where t.transaction_date between ? and ? and a.id between ? and ? " + + "and (t.party_id is null or t.party_id between ? and ?) " + "order by t.transaction_date, t.id"; + final List> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom, + partyTo); + return available("TRANSACTION_REGISTER", List.of("Date", "Type", "Party Code", "Party Name", "Agent Code", "Mode", "Debit", + "Credit", "Balance After", "Description"), rows); + } + + private DdsReportData matureDateWise(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom, final Long agentTo, + final Long partyFrom, final Long partyTo) { + // What the account will be worth beside what it is worth now: this is the list a branch works + // from to arrange payouts, so it has to say who, when and how much - and the shortfall, since + // an account short of its quoted maturity amount is one whose instalments were missed. + final String sql = "select p.party_code as \"Party Code\", p.party_name as \"Party Name\", " + + "a.agent_code as \"Agent Code\", p.opening_date as \"Opening Date\", " + + "p.maturity_date as \"Maturity Date\", p.current_balance as \"Current Balance\", " + + "p.maturity_amount as \"Maturity Amount\", " + + "(p.maturity_amount - p.current_balance) as \"Shortfall\" " + + "from m_dds_party p join m_dds_agent a on a.id = p.agent_id " + // Open accounts only: this is the list payouts are arranged from, and a closed account + // has nothing left to pay. + + "where p.status = 'ACTIVE' and p.maturity_date is not null and p.maturity_date between ? and ? " + + "and p.agent_id between ? and ? " + + "and p.id between ? and ? order by p.maturity_date, p.party_code"; + final List> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom, + partyTo); + return available("MATURE_DATE_WISE", List.of("Party Code", "Party Name", "Agent Code", "Opening Date", "Maturity Date", + "Current Balance", "Maturity Amount", "Shortfall"), rows); + } + + /** + * The interest a customer earned over a period - a document that leaves the bank and goes to the + * customer, who may file it with a tax return. + * + * So it carries what identifies the holder to someone outside the bank: their address and, where + * they have given it, their PAN. It also states the rate, since a certificate that gives an + * amount without the rate it was earned at cannot be checked by the person receiving it. + */ + private DdsReportData interestCertificate(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom, + final Long agentTo, final Long partyFrom, final Long partyTo) { + final String sql = "select p.party_code as \"Party Code\", p.party_name as \"Party Name\", " + + "p.address1 as \"Address\", p.city as \"City\", p.pan_card_no as \"PAN\", " + + "p.opening_date as \"Opening Date\", p.interest_rate as \"Interest Rate %\", " + // Net of interest given back on an early closure, and including what the agent funded + // - the interest the customer actually kept, which is what a tax return needs. + + "p.current_balance as \"Current Balance\", coalesce((select sum(case when t.direction = 'DEBIT' " + + "then -t.amount else t.amount end) from m_dds_party_transaction t " + + "where t.party_id = p.id and t.transaction_type in ('INTEREST_POST', 'OD_INTEREST_POST', " + + "'PREMATURE_AGENT_TOP_UP', 'PREMATURE_INT_RECOVERY') " + + "and t.transaction_date between ? and ?), 0) as \"Interest Earned\" from m_dds_party p " + + "where p.agent_id between ? and ? and p.id between ? and ? order by p.party_code"; + final List> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom, + partyTo); + return available("INTEREST_CERTIFICATE", List.of("Party Code", "Party Name", "Address", "City", "PAN", "Opening Date", + "Interest Rate %", "Current Balance", "Interest Earned"), rows); + } + + /** + * Per-agent settlement statement for a date range: collection volume, commission actually + * posted in that window (via the same BatchRunRow join as COMMISSION_REGISTER), and the + * deduction figures tracked directly on the agent. + * + * "Commission" here is already net of the deductions, because the commission run subtracts them + * when it works the figure out - so the deduction columns show what was taken off, and "Total" + * repeats the net rather than subtracting twice. "PC Diff" has no corresponding data anywhere in + * this schema, so it reports as 0 rather than a guessed figure - matching the legacy sample where + * most of these columns were 0.00 too. No agent id range filter is applied here: the legacy + * screenshot for this report only ever showed a date range, no agent-range inputs. + */ + private DdsReportData agentCommissionStatement(final LocalDate fromDate, final LocalDate toDate) { + final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", " + + "a.agent_saving_account_no as \"A/c No\", coalesce(collection.total, 0) as \"Total Collection\", " + + "coalesce(commission.gross, 0) as \"Commission\", coalesce(commission.tds, 0) as \"TDS\", " + + "a.deduct_commission as \"Deduct Commission\", " + + "a.agent_deposit as \"Agent Deposit\", a.festival_advance as \"Festival Advance\", " + + "a.other_deduction as \"Other Deduction\", coalesce(commission.total, 0) as \"Total\", " + + "0 as \"PC Diff\" " + "from m_dds_agent a " + + "left join (select t.agent_id as agent_id, sum(t.amount) as total from m_dds_party_transaction t " + + "where t.transaction_type = 'PARTY_TRAN' and t.direction = 'CREDIT' and t.transaction_date between ? and ? " + + "group by t.agent_id) collection on collection.agent_id = a.id " + + "left join (select r.agent_id as agent_id, sum(r.gross_amount) as gross, sum(r.tds_amount) as tds, " + + "sum(r.amount) as total from m_dds_batch_run_row r " + "join m_dds_batch_run b on b.id = r.batch_run_id " + + "where b.job_type = 'COMMISSION_CALCULATION' and b.status = 'POSTED' and b.posted_on_date between ? and ? " + + "group by r.agent_id) commission on commission.agent_id = a.id " + "order by a.agent_code"; + final List> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, fromDate, toDate); + return available("AGENT_COMMISSION_STATEMENT", + List.of("Agent Code", "Agent Name", "A/c No", "Total Collection", "Commission", "TDS", "Deduct Commission", + "Agent Deposit", "Festival Advance", "Other Deduction", "Total", "PC Diff"), + rows); + } + + /** + * The legacy Supplementary-DDS (As On) report: every regular collection (PARTY_TRAN, CREDIT) + * posted in the given date range - clarified with the business as "the regular transactions + * going on" (2026-08-24), same shape as the other collection-listing reports. + */ + private DdsReportData supplementaryDds(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom, final Long agentTo, + final Long partyFrom, final Long partyTo) { + // The agent is named as well as coded, the same way the party is - a row that identifies one + // side by name and the other by a code alone reads as an oversight to whoever checks it. + final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", " + + "p.party_code as \"Party Code\", p.party_name as \"Party Name\", " + + "t.transaction_date as \"Transaction Date\", t.transaction_mode as \"Mode\", t.amount as \"Amount\", " + + "t.balance_after_transaction as \"Balance After\" from m_dds_party_transaction t " + + "join m_dds_party p on p.id = t.party_id join m_dds_agent a on a.id = t.agent_id " + + "where t.transaction_type = 'PARTY_TRAN' and t.direction = 'CREDIT' and t.transaction_date between ? and ? " + + "and a.id between ? and ? and p.id between ? and ? order by t.transaction_date, a.agent_code, p.party_code"; + final List> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom, + partyTo); + return available("SUPPLEMENTARY_DDS", List.of("Agent Code", "Agent Name", "Party Code", "Party Name", "Transaction Date", + "Mode", "Amount", "Balance After"), rows); + } + + /** + * The legacy Close Party Ac Register report: Parties closed (via the Close Party command) in the + * given date range, with their close date and final (zero) balance. + */ + private DdsReportData closePartyAcRegister(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom, + final Long agentTo, final Long partyFrom, final Long partyTo) { + final String sql = "select p.party_code as \"Party Code\", p.party_name as \"Party Name\", a.agent_code as \"Agent Code\", " + + "p.opening_date as \"Opening Date\", p.close_date as \"Close Date\", p.current_balance as \"Final Balance\" " + + "from m_dds_party p join m_dds_agent a on a.id = p.agent_id " + + "where p.status = 'CLOSED' and p.close_date between ? and ? and a.id between ? and ? and p.id between ? and ? " + + "order by p.close_date, p.party_code"; + final List> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom, + partyTo); + return available("CLOSE_PARTY_AC_REGISTER", + List.of("Party Code", "Party Name", "Agent Code", "Opening Date", "Close Date", "Final Balance"), rows); + } + + /** + * The legacy Master Data Export report: clarified with the business (2026-08-24) as "select an + * agent, show the full detail of every party under that agent" - filtered by the same agent + * range as every other report here (a single agent is just agentFrom == agentTo). + */ + private DdsReportData masterDataExport(final Long agentFrom, final Long agentTo, final Long partyFrom, final Long partyTo) { + final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", " + + "p.party_code as \"Party Code\", p.party_name as \"Party Name\", p.address1 as \"Address\", " + + "p.city as \"City\", p.pincode as \"Pincode\", p.mobile_no_sms as \"Mobile No\", " + + "p.aadhar_no as \"Aadhar No\", p.pan_card_no as \"PAN No\", p.opening_date as \"Opening Date\", " + + "p.maturity_date as \"Maturity Date\", p.daily_deposit_amount as \"Daily Deposit Amount\", " + + "p.interest_rate as \"Interest Rate\", p.current_balance as \"Current Balance\" " + + "from m_dds_party p join m_dds_agent a on a.id = p.agent_id " + + "where a.id between ? and ? and p.id between ? and ? order by a.agent_code, p.party_code"; + final List> rows = this.jdbcTemplate.queryForList(sql, agentFrom, agentTo, partyFrom, partyTo); + return available("MASTER_DATA_EXPORT", + List.of("Agent Code", "Agent Name", "Party Code", "Party Name", "Address", "City", "Pincode", "Mobile No", "Aadhar No", + "PAN No", "Opening Date", "Maturity Date", "Daily Deposit Amount", "Interest Rate", "Current Balance"), + rows); + } + + private DdsReportData available(final String reportKey, final List columns, final List> rows) { + return DdsReportData.builder().reportKey(reportKey).available(true).columns(columns).rows(normaliseDates(rows)).build(); + } + + /** + * Converts the JDBC date types every row arrives with into the java.time types the API can + * actually render. + * + * {@code queryForList} hands back {@link java.sql.Date} and {@link java.sql.Timestamp}, and + * serialising either of those throws - so until this ran, every report carrying a date column + * failed outright with a 500-shaped error while the ones without dates worked. That was ten of + * the eighteen reports, including Commission Register, Party Interest Register and Mature Date + * Wise. + * + * Done here, in the one place every report's rows pass through, rather than in each query: a + * report added later gets the same treatment without having to know about this. + */ + private List> normaliseDates(final List> rows) { + final List> converted = new ArrayList<>(rows.size()); + for (final Map row : rows) { + // Copied rather than edited in place: BALANCE_SUMMARY builds its single row with Map.of, + // which refuses to be modified. Insertion order is kept, so the columns stay in order. + final Map copy = new LinkedHashMap<>(row.size()); + for (final Map.Entry cell : row.entrySet()) { + final Object value = cell.getValue(); + if (value instanceof java.sql.Timestamp timestamp) { + copy.put(cell.getKey(), timestamp.toLocalDateTime()); + } else if (value instanceof java.sql.Date date) { + copy.put(cell.getKey(), date.toLocalDate()); + } else { + copy.put(cell.getKey(), value); + } + } + converted.add(copy); + } + return converted; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/report/starter/DdsReportConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/report/starter/DdsReportConfiguration.java new file mode 100644 index 000000000..4b190bc76 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/report/starter/DdsReportConfiguration.java @@ -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.report.starter; + +import org.apache.fineract.portfolio.dds.report.service.DdsReportReadPlatformService; +import org.apache.fineract.portfolio.dds.report.service.DdsReportReadPlatformServiceImpl; +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 DdsReportConfiguration { + + @Bean + @ConditionalOnMissingBean(DdsReportReadPlatformService.class) + public DdsReportReadPlatformService ddsReportReadPlatformService(JdbcTemplate jdbcTemplate) { + return new DdsReportReadPlatformServiceImpl(jdbcTemplate); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/api/PartyTransactionsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/api/PartyTransactionsApiResource.java new file mode 100644 index 000000000..68fb5653c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/api/PartyTransactionsApiResource.java @@ -0,0 +1,95 @@ +/** + * 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.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.Collection; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService; +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.transaction.data.PartyTransactionData; +import org.apache.fineract.portfolio.dds.transaction.service.PartyTransactionReadPlatformService; +import org.springframework.stereotype.Component; + +@Path("/v1/dds/partytransactions") +@Component +@Tag(name = "DDS Party Transactions", description = "The Daily Transaction Entry ledger: a day's PARTY_TRAN collection rows, the two legs of a WITHDRAW_DEPOSITE transfer between two parties, or an AGENT_TRAN settlement of an Agent's collected cash to its branch. Immutable once posted - reversed with an opposing entry, never edited.") +@RequiredArgsConstructor +public class PartyTransactionsApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSIONS = "PARTYTRANSACTION"; + + private final PlatformSecurityContext context; + private final PartyTransactionReadPlatformService readPlatformService; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final DdsVoucherService ddsVoucherService; + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve DDS Party Transactions", description = "Returns the list of posted DDS party transactions, most recent first. Optionally filter by agentId and/or partyId.") + public Collection retrieveAll(@QueryParam("agentId") @Parameter(description = "agentId") final Long agentId, + @QueryParam("partyId") @Parameter(description = "partyId") final Long partyId) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return readPlatformService.retrieveAll(agentId, partyId); + } + + @GET + @Path("{transactionId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve a DDS Party Transaction", description = "Returns the details of a single posted DDS party transaction.") + public PartyTransactionData retrieveOne(@PathParam("transactionId") @Parameter(description = "transactionId") final Long transactionId) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return readPlatformService.retrieveOne(transactionId); + } + + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Post DDS Party Transactions", description = "Mandatory fields: agentId, transactionType (PARTY_TRAN, " + + "WITHDRAW_DEPOSITE or AGENT_TRAN), transactionDate\n" + "\n" + + "For PARTY_TRAN: collections (array of {partyCode, transactionMode, accountType, accountNo, amount, chequeNo, tokenNo, " + + "description})\n" + "\n" + "For WITHDRAW_DEPOSITE: creditorPartyCode, debitPartyCode, withdrawAmount, withdrawDescription\n" + + "\n" + "For AGENT_TRAN: amount, transactionMode, accountType, accountNo, description") + public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) { + final CommandWrapper commandRequest = new CommandWrapperBuilder() // + .createPartyTransaction() // + .withJson(apiRequestBodyAsJson) // + .build(); + return ddsVoucherService.submitHoldingVoucher(DdsVoucherService.TYPE_TRANSFER, DdsVoucherService.REF_AGENT, + DdsVoucherService.idIn(apiRequestBodyAsJson, "agentId"), + () -> DdsVoucherService.numberIn(apiRequestBodyAsJson, "withdrawAmount"), + () -> commandsSourceWritePlatformService.logCommandSource(commandRequest)); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/domain/PartyTransactionRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/domain/PartyTransactionRepository.java new file mode 100644 index 000000000..745d79771 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/domain/PartyTransactionRepository.java @@ -0,0 +1,59 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.transaction.domain; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Collection; +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 PartyTransactionRepository extends JpaRepository { + + @Query("select coalesce(sum(t.amount), 0) from PartyTransaction t where t.agent.id = :agentId " + + "and t.transactionType = 'PARTY_TRAN' and t.direction = 'CREDIT' and t.transactionDate between :fromDate and :toDate") + BigDecimal sumPartyTranCredits(@Param("agentId") Long agentId, @Param("fromDate") LocalDate fromDate, + @Param("toDate") LocalDate toDate); + + /** + * What one account has been collected in total against its instalments. + * + * Only PARTY_TRAN counts. Interest is the bank's to pay and a transfer in from another party is + * not this customer keeping to their schedule; counting either would show an account as up to date + * on days nobody collected. The collection screen's own figure is read the same way, so the two + * cannot disagree. + */ + @Query("select coalesce(sum(t.amount), 0) from PartyTransaction t where t.party.id = :partyId " + + "and t.transactionType = 'PARTY_TRAN' and t.direction = 'CREDIT'") + BigDecimal sumCollectedFor(@Param("partyId") Long partyId); + + /** + * Every movement on these accounts up to the given date, oldest first. + * + * Interest on a daily-deposit account cannot be worked out from the closing balance alone: the + * money arrives a little at a time, so each instalment has to earn over the days it was actually + * held. That means replaying the account's movements, which is what these rows are for. Fetched + * for the whole batch in one query and grouped in memory, rather than once per account. + */ + @Query("select t from PartyTransaction t where t.party.id in :partyIds and t.transactionDate <= :toDate " + + "order by t.party.id, t.transactionDate, t.id") + List findForPartiesUpTo(@Param("partyIds") Collection partyIds, @Param("toDate") LocalDate toDate); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/exception/PartyAdvanceAmountMismatchException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/exception/PartyAdvanceAmountMismatchException.java new file mode 100644 index 000000000..da377e194 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/exception/PartyAdvanceAmountMismatchException.java @@ -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.transaction.exception; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +/** + * Thrown when a row paying by days - ahead or in arrears - carries an amount those days do not come to. + * + * Days paid by are worth the scheme's daily instalment times the days - there is nothing to + * negotiate. An amount that disagrees means the screen quoted the customer one figure and the request + * asked to post another, and there is no way to tell which the customer actually handed over. + * + * So it is refused rather than resolved. Taking the computed figure would post money the customer may + * not have paid; taking the sent figure would let the daily amount be worked around a few days at a + * time. + */ +public class PartyAdvanceAmountMismatchException extends AbstractPlatformDomainRuleException { + + public PartyAdvanceAmountMismatchException(final String partyCode, final int days, final BigDecimal expected, + final BigDecimal sent, final boolean isAdvance) { + super("error.msg.dds.party.day.amount.mismatch", + days + (isAdvance ? " days in advance on " : " days of arrears on ") + partyCode + " comes to " + + expected.setScale(2, RoundingMode.HALF_UP) + ", but " + sent.setScale(2, RoundingMode.HALF_UP) + + " was sent. Take the days off the scheme's own daily amount, or post it as an ordinary " + + "collection instead.", + partyCode, days, expected, sent); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/exception/PartyArrearsDaysExceededException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/exception/PartyArrearsDaysExceededException.java new file mode 100644 index 000000000..5aa885246 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/exception/PartyArrearsDaysExceededException.java @@ -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.transaction.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +/** + * Thrown when more days of arrears are collected than this account actually has to give. + * + * Two things limit it, and this covers both. An account cannot pay off more days than it is behind - + * a customer one day short does not owe three. And an account cannot reach back past the catch-up + * window; instalments older than that are skipped, and skipped means the bank does not collect them. + * + * Without this, arrears would be a way round the daily amount: any figure at all could be posted by + * calling it a number of days. What is left when a day is refused is not lost to the customer - it can + * still be paid as an ordinary collection, it just cannot be dressed up as clearing days that are gone. + */ +public class PartyArrearsDaysExceededException extends AbstractPlatformDomainRuleException { + + public PartyArrearsDaysExceededException(final String partyCode, final int requested, final int allowed) { + super("error.msg.dds.party.arrears.days.exceeded", + allowed == 0 + ? "Account " + partyCode + " has no arrears left that can still be collected, so " + requested + + " days cannot be taken against it." + : "Account " + partyCode + " has " + allowed + " " + (allowed == 1 ? "day" : "days") + + " of arrears that can still be collected, but " + requested + " were asked for.", + partyCode, requested, allowed); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/exception/PartyHasNoDailyAmountException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/exception/PartyHasNoDailyAmountException.java new file mode 100644 index 000000000..3c3204231 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/exception/PartyHasNoDailyAmountException.java @@ -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 days are taken in advance on an account that has no daily instalment to multiply. + * + * Every scheme account is opened with one, so this is a record that should not exist rather than an + * ordinary refusal - it says the account is wrong, not the request. An ordinary collection on such an + * account still works, because the clerk types what was handed over. + */ +public class PartyHasNoDailyAmountException extends AbstractPlatformDomainRuleException { + + public PartyHasNoDailyAmountException(final String partyCode) { + super("error.msg.dds.party.has.no.daily.amount", + "Account " + partyCode + " has no daily deposit amount, so days cannot be taken in advance against it. " + + "Post what was collected as an ordinary amount, and have the account's daily figure corrected.", + partyCode); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/handler/CreatePartyTransactionCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/handler/CreatePartyTransactionCommandHandler.java new file mode 100644 index 000000000..73a95f95e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/handler/CreatePartyTransactionCommandHandler.java @@ -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.transaction.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.transaction.service.PartyTransactionWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "PARTYTRANSACTION", action = "CREATE") +public class CreatePartyTransactionCommandHandler implements NewCommandSourceHandler { + + private final PartyTransactionWritePlatformService writePlatformService; + + @Autowired + public CreatePartyTransactionCommandHandler(final PartyTransactionWritePlatformService writePlatformService) { + this.writePlatformService = writePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.createPartyTransaction(command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/serialization/PartyTransactionCommandFromApiJsonDeserializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/serialization/PartyTransactionCommandFromApiJsonDeserializer.java new file mode 100644 index 000000000..13796cd26 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/serialization/PartyTransactionCommandFromApiJsonDeserializer.java @@ -0,0 +1,210 @@ +/** + * 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.serialization; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.reflect.TypeToken; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; +import org.apache.fineract.infrastructure.core.exception.InvalidJsonException; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.portfolio.dds.calculation.DdsSchedule; +import org.apache.fineract.portfolio.dds.transaction.domain.DdsTransactionMode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * PARTY_TRAN (batch collection rows), WITHDRAW_DEPOSITE (a transfer between two parties) and + * AGENT_TRAN (an Agent settling its collected cash to a branch, no party involved) are + * validated/processed - the three transaction types the Daily Transaction Entry screen actually + * collects fields for today. PARTY_PAYMENT / OD_ENTRY / OD_PAYMENT are listed on the frontend as + * not-yet-implemented placeholders, so they are rejected here rather than silently accepted with no + * real effect. + */ +@Component +public final class PartyTransactionCommandFromApiJsonDeserializer { + + /** + * A List, not a Set, on purpose: it selects DataValidatorBuilder's List overload of + * isOneOfTheseStringValues, which lowercases both sides before comparing. The varargs overload + * lowercases only the submitted value, so an upper-case set like this one never matches and + * every request is rejected. Ordering is fixed too, so the error message reads the same each time. + */ + private static final List SUPPORTED_TRANSACTION_TYPES = List.of("PARTY_TRAN", "AGENT_TRAN", "WITHDRAW_DEPOSITE"); + + private static final Set SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("agentId", "transactionType", + "transactionDate", "collections", "creditorPartyCode", "debitPartyCode", "withdrawAmount", "withdrawDescription", + "amount", "transactionMode", "accountType", "accountNo", "description", "dateFormat", "locale")); + + private static final Set SUPPORTED_COLLECTION_PARAMETERS = new HashSet<>(Arrays.asList("partyCode", "transactionMode", + "accountType", "accountNo", "amount", "advanceDays", "arrearsDays", "chequeNo", "tokenNo", "description")); + + /** + * The array a collection row sits in. Named once because every error raised against a row has to + * carry it: the validator builds "collections[0][accountNo]" from the array name and the field + * name together, so a field named without its array reads back as "null[0][accountNo]". + */ + private static final String COLLECTIONS = "collections"; + + private static final String TRANSACTION_MODE = "transactionMode"; + + private final FromJsonHelper fromApiJsonHelper; + + @Autowired + public PartyTransactionCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) { + this.fromApiJsonHelper = fromApiJsonHelper; + } + + public void validateForCreate(final String json) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + + final Type typeOfMap = new TypeToken>() { + + }.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("partytransaction"); + + final JsonElement element = this.fromApiJsonHelper.parse(json); + + v.reset().parameter("agentId").value(this.fromApiJsonHelper.extractLongNamed("agentId", element)).notNull() + .integerGreaterThanZero(); + + final String transactionType = this.fromApiJsonHelper.extractStringNamed("transactionType", element); + v.reset().parameter("transactionType").value(transactionType).notBlank() + .isOneOfTheseStringValues(SUPPORTED_TRANSACTION_TYPES); + + v.reset().parameter("transactionDate").value(this.fromApiJsonHelper.extractLocalDateNamed("transactionDate", element)).notNull(); + + if ("PARTY_TRAN".equals(transactionType)) { + if (!this.fromApiJsonHelper.parameterExists(COLLECTIONS, element)) { + v.reset().parameter(COLLECTIONS).value(null).notNull(); + } else { + final JsonObject topLevel = element.getAsJsonObject(); + final var collectionsArray = topLevel.getAsJsonArray(COLLECTIONS); + v.reset().parameter(COLLECTIONS).value(collectionsArray).jsonArrayNotEmpty(); + for (int i = 0; i < collectionsArray.size(); i++) { + final JsonObject row = collectionsArray.get(i).getAsJsonObject(); + this.fromApiJsonHelper.checkForUnsupportedParameters(row, SUPPORTED_COLLECTION_PARAMETERS); + + v.reset().parameter(COLLECTIONS).parameterAtIndexArray("partyCode", i).value(extractString(row, "partyCode")) + .notBlank(); + v.reset().parameter(COLLECTIONS).parameterAtIndexArray("accountNo", i).value(extractString(row, "accountNo")) + .notBlank(); + + // Which way the money came in, on the row it came in on. Checked per row rather + // than once for the batch because a clerk settles a morning's round in one go, + // and the rounds are mixed - three in cash, one by cheque. + final String rowMode = extractString(row, TRANSACTION_MODE); + v.reset().parameter(COLLECTIONS).parameterAtIndexArray(TRANSACTION_MODE, i).value(rowMode).notBlank(); + // The row has to be named in the message itself. A pattern failure carries only + // the parameter, never the row it came from, so on a round of twenty rows the + // clerk would otherwise be told a mode is wrong without being told which one - + // the same reason the advance/arrears clash below counts the row out by hand. + v.reset().parameter(COLLECTIONS).value(rowMode).ignoreIfNull().matchesRegularExpression( + DdsTransactionMode.acceptedPattern(), + "Row " + (i + 1) + ": payment mode must be one of " + DdsTransactionMode.labelsForMessage()); + + // A row is one of three things: a plain collection, where the clerk types what was + // handed over; days taken in advance; or days of arrears being cleared. All three + // carry an amount in the end, but the day-based two have theirs worked out from + // the scheme rather than typed, and both are capped - the amount is checked against + // the account itself in the write service, which is where the account is loaded. + // + // A mistyped figure on a plain row is the clerk's to explain and gets no more than + // a warning on screen; paying ahead and reaching back are things the bank limits. + final Integer advanceDays = extractInteger(row, "advanceDays"); + final Integer arrearsDays = extractInteger(row, "arrearsDays"); + + if (advanceDays != null && arrearsDays != null) { + // failWithCode carries only the parameter, never the row - so the row is passed + // as a message argument instead, counted from one the way a clerk reads a list. + v.reset().parameter(COLLECTIONS).failWithCode("cannot.be.both.advance.and.arrears", i + 1); + } + if (advanceDays != null) { + // Both, because the range check names the parameter while the array helpers + // only carry the row it came from - set alone, the message reads "`null`". + v.reset().parameter("advanceDays").parameterAtIndexArray("advanceDays", i).value(advanceDays) + .inMinMaxRange(1, DdsSchedule.MAX_ADVANCE_DAYS); + } else if (arrearsDays != null) { + v.reset().parameter("arrearsDays").parameterAtIndexArray("arrearsDays", i).value(arrearsDays) + .inMinMaxRange(1, DdsSchedule.CATCH_UP_DAYS); + } else { + v.reset().parameter(COLLECTIONS).parameterAtIndexArray("amount", i).value(extractBigDecimal(row, "amount")) + .notNull().positiveAmount(); + } + } + } + } else if ("WITHDRAW_DEPOSITE".equals(transactionType)) { + v.reset().parameter("creditorPartyCode").value(this.fromApiJsonHelper.extractStringNamed("creditorPartyCode", element)) + .notBlank(); + v.reset().parameter("debitPartyCode").value(this.fromApiJsonHelper.extractStringNamed("debitPartyCode", element)) + .notBlank(); + v.reset().parameter("withdrawAmount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("withdrawAmount", element)) + .notNull().positiveAmount(); + } else if ("AGENT_TRAN".equals(transactionType)) { + v.reset().parameter("amount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("amount", element)).notNull() + .positiveAmount(); + + // An agent handing its round's takings to the branch is a counter transaction like any + // other, so it says how the money arrived on the same terms a collection does. + final String settlementMode = this.fromApiJsonHelper.extractStringNamed(TRANSACTION_MODE, element); + v.reset().parameter(TRANSACTION_MODE).value(settlementMode).notBlank(); + v.reset().parameter(TRANSACTION_MODE).value(settlementMode).ignoreIfNull().matchesRegularExpression( + DdsTransactionMode.acceptedPattern(), "Payment mode must be one of " + DdsTransactionMode.labelsForMessage()); + } + // WITHDRAW_DEPOSITE deliberately carries no mode: it moves a balance from one DDS account to + // another inside the bank's own books. Nothing crosses the counter, so there is nothing for a + // mode to describe, and asking for one would only invite a meaningless "Cash". + + 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 java.math.BigDecimal extractBigDecimal(final JsonObject json, final String paramName) { + return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsBigDecimal() : null; + } + + private Integer extractInteger(final JsonObject json, final String paramName) { + return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsInt() : null; + } + + private void throwExceptionIfValidationWarningsExist(final List dataValidationErrors) { + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", + dataValidationErrors); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/service/PartyTransactionReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/service/PartyTransactionReadPlatformService.java new file mode 100644 index 000000000..81736cc39 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/service/PartyTransactionReadPlatformService.java @@ -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.transaction.service; + +import java.util.Collection; +import org.apache.fineract.portfolio.dds.transaction.data.PartyTransactionData; + +public interface PartyTransactionReadPlatformService { + + Collection retrieveAll(Long agentId, Long partyId); + + PartyTransactionData retrieveOne(Long transactionId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/service/PartyTransactionReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/service/PartyTransactionReadPlatformServiceImpl.java new file mode 100644 index 000000000..74a0c0c41 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/service/PartyTransactionReadPlatformServiceImpl.java @@ -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.portfolio.dds.transaction.service; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Collection; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.domain.JdbcSupport; +import org.apache.fineract.infrastructure.security.utils.SQLBuilder; +import org.apache.fineract.portfolio.dds.transaction.data.PartyTransactionData; +import org.apache.fineract.portfolio.dds.transaction.exception.PartyTransactionNotFoundException; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +@RequiredArgsConstructor +public class PartyTransactionReadPlatformServiceImpl implements PartyTransactionReadPlatformService { + + private final JdbcTemplate jdbcTemplate; + + private static final PartyTransactionMapper MAPPER = new PartyTransactionMapper(); + + private static final class PartyTransactionMapper implements RowMapper { + + public String schema() { + return " t.id as id, t.party_id as partyId, p.party_code as partyCode, p.party_name as partyName, " + + " t.agent_id as agentId, a.agent_code as agentCode, t.transaction_date as transactionDate, " + + " t.transaction_type as transactionType, t.direction as direction, t.transaction_mode as transactionMode, " + + " t.account_type as accountType, t.account_no as accountNo, t.amount as amount, t.cheque_no as chequeNo, " + + " t.token_no as tokenNo, t.description as description, t.balance_after_transaction as balanceAfterTransaction, t.voucher_no as voucherNo " + + " from m_dds_party_transaction t left join m_dds_party p on p.id = t.party_id " + + " join m_dds_agent a on a.id = t.agent_id"; + } + + @Override + public PartyTransactionData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + return PartyTransactionData.builder() // + .id(rs.getLong("id")) // + .partyId(JdbcSupport.getLongDefaultToNullIfZero(rs, "partyId")) // + .partyCode(rs.getString("partyCode")) // + .partyName(rs.getString("partyName")) // + .agentId(rs.getLong("agentId")) // + .agentCode(rs.getString("agentCode")) // + .transactionDate(JdbcSupport.getLocalDate(rs, "transactionDate")) // + .transactionType(rs.getString("transactionType")) // + .direction(rs.getString("direction")) // + .transactionMode(rs.getString("transactionMode")) // + .accountType(rs.getString("accountType")) // + .accountNo(rs.getString("accountNo")) // + .amount(rs.getBigDecimal("amount")) // + .chequeNo(rs.getString("chequeNo")) // + .tokenNo(rs.getString("tokenNo")) // + .description(rs.getString("description")) // + .balanceAfterTransaction(rs.getBigDecimal("balanceAfterTransaction")) // + .voucherNo(rs.getString("voucherNo")) // + .build(); + } + } + + @Override + public Collection retrieveAll(final Long agentId, final Long partyId) { + final SQLBuilder extraCriteria = new SQLBuilder(); + extraCriteria.addNonNullCriteria(" t.agent_id = ", agentId); + extraCriteria.addNonNullCriteria(" t.party_id = ", partyId); + + String sql = "select " + MAPPER.schema() + " " + extraCriteria.getSQLTemplate(); + sql = sql + " order by t.id desc"; + + return this.jdbcTemplate.query(sql, MAPPER, extraCriteria.getArguments()); // NOSONAR + } + + @Override + public PartyTransactionData retrieveOne(final Long transactionId) { + try { + final String sql = "select " + MAPPER.schema() + " where t.id = ?"; + return this.jdbcTemplate.queryForObject(sql, MAPPER, transactionId); // NOSONAR + } catch (final EmptyResultDataAccessException e) { + throw new PartyTransactionNotFoundException(transactionId, e); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/service/PartyTransactionWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/service/PartyTransactionWritePlatformService.java new file mode 100644 index 000000000..7ce31f29a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/service/PartyTransactionWritePlatformService.java @@ -0,0 +1,48 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.dds.transaction.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.organisation.agent.domain.Agent; +import org.apache.fineract.portfolio.dds.party.domain.Party; +import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransaction; + +public interface PartyTransactionWritePlatformService { + + CommandProcessingResult createPartyTransaction(JsonCommand command); + + /** + * Posts one collection: the customer is credited, the agent's cash goes up, and both the DDS + * ledger and the bank's books are written. + * + * Exposed because a written-down entry becomes a real credit through exactly this, one entry at + * a time, when a clerk settles an agent's day. The alternative was a second implementation of + * the same posting, and two postings of the same thing drift. + * + * It posts what it is given and checks nothing: whether this party may be collected from by this + * agent, and whether the amount is a permitted one, is settled before the entry is written down + * and again before it is settled. Callers that have not established those have no business + * calling this. + */ + PartyTransaction postSingleCollection(Agent agent, Party party, LocalDate transactionDate, BigDecimal amount, String transactionMode, + String accountType, String accountNo, String chequeNo, String tokenNo, String description); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/service/PartyTransactionWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/service/PartyTransactionWritePlatformServiceJpaRepositoryImpl.java new file mode 100644 index 000000000..11d81deb3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/service/PartyTransactionWritePlatformServiceJpaRepositoryImpl.java @@ -0,0 +1,388 @@ +/** + * 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.service; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import jakarta.persistence.PersistenceException; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.exception.ErrorHandler; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.organisation.agent.domain.Agent; +import org.apache.fineract.organisation.agent.domain.AgentRepository; +import org.apache.fineract.organisation.agent.exception.AgentNotFoundException; +import org.apache.fineract.organisation.workingdays.domain.WorkingDays; +import org.apache.fineract.organisation.workingdays.domain.WorkingDaysRepositoryWrapper; +import org.apache.fineract.organisation.workingdays.service.WorkingDaysUtil; +import org.apache.fineract.portfolio.dds.accounting.DdsLedger; +import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService; +import org.apache.fineract.portfolio.dds.calculation.DdsSchedule; +import org.apache.fineract.portfolio.dds.party.domain.Party; +import org.apache.fineract.portfolio.dds.party.exception.PartyHeldUnderLienException; +import org.apache.fineract.portfolio.dds.party.domain.PartyRepository; +import org.apache.fineract.portfolio.dds.party.exception.PartyAlreadyClosedException; +import org.apache.fineract.portfolio.dds.party.exception.PartyNotFoundException; +import org.apache.fineract.portfolio.dds.transaction.domain.DdsTransactionMode; +import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransaction; +import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository; +import org.apache.fineract.portfolio.dds.transaction.exception.InsufficientAgentBalanceException; +import org.apache.fineract.portfolio.dds.transaction.exception.PartyAdvanceAmountMismatchException; +import org.apache.fineract.portfolio.dds.transaction.exception.PartyArrearsDaysExceededException; +import org.apache.fineract.portfolio.dds.transaction.exception.PartyHasNoDailyAmountException; +import org.apache.fineract.portfolio.dds.transaction.exception.InsufficientPartyBalanceException; +import org.apache.fineract.portfolio.dds.transaction.exception.PartyNotCollectedByAgentException; +import org.apache.fineract.portfolio.dds.transaction.serialization.PartyTransactionCommandFromApiJsonDeserializer; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.jpa.JpaSystemException; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +public class PartyTransactionWritePlatformServiceJpaRepositoryImpl implements PartyTransactionWritePlatformService { + + private final PartyTransactionCommandFromApiJsonDeserializer fromApiJsonDeserializer; + private final PartyTransactionRepository partyTransactionRepository; + private final PartyRepository partyRepository; + private final AgentRepository agentRepository; + + /** Puts each movement onto the bank's own books as well as DDS's - see DdsLedger. */ + private final DdsLedger ddsLedger; + + /** The bank's own working-days setting - which days the branch opens. See bankingDateFor. */ + private final WorkingDaysRepositoryWrapper workingDaysRepository; + + @Transactional + @Override + public CommandProcessingResult createPartyTransaction(final JsonCommand command) { + // One DDS voucher for the entry - a transfer names both accounts even though no ledger leg moves. + final String voucherType = "WITHDRAW_DEPOSITE".equals(command.stringValueOfParameterNamed("transactionType")) + ? DdsVoucherService.TYPE_TRANSFER + : DdsVoucherService.TYPE_TRANSACTION; + return this.ddsLedger.withVoucher(voucherType, DdsVoucherService.REF_AGENT, command.longValueOfParameterNamed("agentId"), + () -> createWithinVoucher(command)); + } + + private CommandProcessingResult createWithinVoucher(final JsonCommand command) { + + try { + this.fromApiJsonDeserializer.validateForCreate(command.json()); + + final Long agentId = command.longValueOfParameterNamed("agentId"); + final Agent agent = this.agentRepository.findById(agentId) + .orElseThrow(() -> new AgentNotFoundException(agentId)); + + final String transactionType = command.stringValueOfParameterNamed("transactionType"); + // The day it reaches the bank's books, which is the collection day itself unless the + // branch was shut that day - see bankingDateFor. + final LocalDate transactionDate = bankingDateFor(command.localDateValueOfParameterNamed("transactionDate")); + + final List posted = new ArrayList<>(); + if ("PARTY_TRAN".equals(transactionType)) { + posted.addAll(postCollections(agent, transactionDate, command)); + } else if ("AGENT_TRAN".equals(transactionType)) { + posted.add(postAgentSettlement(agent, transactionDate, command)); + } else { + posted.addAll(postWithdrawDeposite(agent, transactionDate, command)); + } + + this.partyTransactionRepository.saveAllAndFlush(posted); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(posted.isEmpty() ? null : posted.get(0).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(); + } + } + + /** + * The day a collection reaches the bank's books, which is not always the day it was collected. + * + * The agent calls every day of the scheme, weekends included, so money is taken on days the branch + * does not open. That money is not banked until the branch next opens, and the entry belongs on + * that day - posting it on the closed day would put DDS's ledger on a date the bank's own books + * have nothing on, and show cash reaching the branch before it did. + * + * The collection itself is never refused. A Saturday's takings are entered and simply carry + * Monday's date, along with Sunday's and Monday's own. + * + * Both which days the branch opens and what to do about a date that falls outside them are the + * bank's own working-days setting, read here rather than assumed - a bank that opens on Saturdays + * changes it there and nothing moves. Today that setting names all seven days, so nothing shifts + * at all; this earns its place the day the bank closes at the weekend. + */ + private LocalDate bankingDateFor(final LocalDate collectionDate) { + if (collectionDate == null) { + return null; + } + final WorkingDays workingDays = this.workingDaysRepository.findOne(); + if (workingDays == null) { + return collectionDate; + } + final LocalDate banked = WorkingDaysUtil.getOffSetDateIfNonWorkingDay(collectionDate, null, workingDays); + // The setting can name a rule this has no meeting date to answer - fall back to the day given + // rather than posting against nothing. + return banked == null ? collectionDate : banked; + } + + /** + * What this row actually collects. + * + * Usually the figure the clerk typed, being what the customer handed over. Where the row says + * days taken in advance instead, the amount is the scheme's own - the daily instalment times the + * days - and is worked out here rather than trusted from the request: the screen shows the + * customer a figure before they pay, and the two have to be the same figure. + * + * A row may carry both, and then they must agree. A mismatch means the screen quoted one amount + * and the request asked to post another, which is worth stopping over rather than silently + * preferring one. + */ + private BigDecimal amountOf(final JsonObject row, final Party party) { + final Integer days = wholeOf(row, "advanceDays") != null ? wholeOf(row, "advanceDays") : wholeOf(row, "arrearsDays"); + if (days == null) { + return row.get("amount").getAsBigDecimal(); + } + final boolean isAdvance = wholeOf(row, "advanceDays") != null; + + final BigDecimal daily = party.getDailyDepositAmount(); + if (daily == null || daily.compareTo(BigDecimal.ZERO) <= 0) { + throw new PartyHasNoDailyAmountException(party.getPartyCode()); + } + + // Arrears are capped by what this account actually still owes inside the catch-up window, not + // by a flat number of days: an account one day behind cannot pay three off, and an account + // whose missed days have fallen outside the window cannot pay them at all. The screen offers + // only what is allowed, but the screen is not the only way in. + if (!isAdvance) { + final int allowed = scheduleOf(party).recoverableDays(daily); + if (days > allowed) { + throw new PartyArrearsDaysExceededException(party.getPartyCode(), days, allowed); + } + } + + final BigDecimal dueAmount = daily.multiply(BigDecimal.valueOf(days)); + + if (row.has("amount") && !row.get("amount").isJsonNull()) { + final BigDecimal quoted = row.get("amount").getAsBigDecimal(); + if (quoted.compareTo(dueAmount) != 0) { + throw new PartyAdvanceAmountMismatchException(party.getPartyCode(), days, dueAmount, quoted, isAdvance); + } + } + return dueAmount; + } + + private Integer wholeOf(final JsonObject row, final String field) { + return row.has(field) && !row.get(field).isJsonNull() ? row.get(field).getAsInt() : null; + } + + /** + * Where this account stands today - the same figures the collection screen shows, from the same + * calculation, so a clerk is never quoted an amount the server then refuses. + */ + private DdsSchedule scheduleOf(final Party party) { + final int due = DdsSchedule.instalmentsDue(party.getOpeningDate(), party.getDays(), DateUtils.getBusinessLocalDate()); + final BigDecimal collected = this.partyTransactionRepository.sumCollectedFor(party.getId()); + return DdsSchedule.of(party.getDailyDepositAmount(), due, collected); + } + + private List postCollections(final Agent agent, final LocalDate transactionDate, final JsonCommand command) { + final List transactions = new ArrayList<>(); + final JsonArray collectionsArray = command.arrayOfParameterNamed("collections"); + for (int i = 0; i < collectionsArray.size(); i++) { + final JsonObject row = collectionsArray.get(i).getAsJsonObject(); + final String partyCode = row.get("partyCode").getAsString(); + final Party party = partyOf(partyCode, agent); + final BigDecimal amount = amountOf(row, party); + transactions.add(postSingleCollection(agent, party, transactionDate, amount, stringOrNull(row, "transactionMode"), + stringOrNull(row, "accountType"), stringOrNull(row, "accountNo"), stringOrNull(row, "chequeNo"), + stringOrNull(row, "tokenNo"), stringOrNull(row, "description"))); + } + return transactions; + } + + /** + * Credits one collection to a customer and puts the cash in the agent's hands, on DDS's ledger + * and on the bank's. + * + * Held apart from the round that calls it because a second caller now settles an agent's + * written-down day, one entry at a time, and the two must post identically. Anything that lived + * only in the loop above would be a thing the settled entries silently did without - and the + * difference would show up as a customer credited by one route and not the other. + */ + @Override + public PartyTransaction postSingleCollection(final Agent agent, final Party party, final LocalDate transactionDate, + final BigDecimal amount, final String transactionMode, final String accountType, final String accountNo, + final String chequeNo, final String tokenNo, final String description) { + + final BigDecimal newBalance = party.credit(amount); + agent.credit(amount); + + // The same money on the bank's own books: onto what the customer is owed - their savings + // account where they still have one, DDS Customer Deposits otherwise - and into the agent's + // cash until the agent hands it over. + this.ddsLedger.collect(party, transactionDate, amount, "DDS collection by " + agent.getAgentCode(), agent.getGlAccount()); + + // Saved as it is made, rather than the whole round together at the end. + // + // A statement is read in id order, and each row carries the balance as it stood when that + // row was applied. Saving the round in one call leaves the ids to Hibernate, which is free + // to write them in any order it likes - and it does: a round of three came back with the + // later collection holding the lower id, so the passbook read as though the balance fell + // after a deposit. The figures were right and the story they told was not. + // + // One insert per collection instead of one per round is the price, and a round is tens of + // rows rather than thousands. + return this.partyTransactionRepository.saveAndFlush(new PartyTransaction(party, agent, transactionDate, "PARTY_TRAN", "CREDIT", + DdsTransactionMode.canonicalOrOriginal(transactionMode), accountType, accountNo, amount, chequeNo, tokenNo, description, + newBalance)); + } + + private PartyTransaction postAgentSettlement(final Agent agent, final LocalDate transactionDate, final JsonCommand command) { + // An agent's cash is taken in at the cashier's counter now - Cashier > Transaction Entry, a GL + // receipt tagged DDS into the agent's own GL account - and applied to the customers from Agent + // Tran > Apply DDS Receipt. Taking the same cash in here as well would be a second way in for + // one bag of notes, so this way is closed, and says where the right one is. + if (agent != null) { + throw new PlatformDataIntegrityException("error.msg.dds.agent.tran.moved.to.cashier", + "An agent's cash is no longer taken in here. The cashier receipts it at Cashier > Transaction Entry " + + "(Receipt, Cash, GL, Entry Type DDS, A/C No " + (agent.getGlAccount() != null ? agent.getGlAccount().getGlCode() + : "the agent's GL account") + "), and it is applied from Daily Transaction Detail > Agent Tran > " + + "Apply DDS Receipt."); + } + final BigDecimal amount = command.bigDecimalValueOfParameterNamed("amount"); + if (agent.getAgentCurrentBalance().compareTo(amount) < 0) { + throw new InsufficientAgentBalanceException(agent.getAgentCode()); + } + final BigDecimal newBalance = agent.settle(amount); + + // The cash reaches the branch. No customer is involved - they were credited when their money + // was collected; this is the bank catching up with where the notes actually are. + this.ddsLedger.settle(agent.getOffice(), transactionDate, amount, "DDS settlement by " + agent.getAgentCode(), agent.getGlAccount()); + + return new PartyTransaction(null, agent, transactionDate, "AGENT_TRAN", "DEBIT", + DdsTransactionMode.canonicalOrOriginal(command.stringValueOfParameterNamedAllowingNull("transactionMode")), + command.stringValueOfParameterNamedAllowingNull("accountType"), + command.stringValueOfParameterNamedAllowingNull("accountNo"), amount, null, null, + command.stringValueOfParameterNamedAllowingNull("description"), newBalance); + } + + private List postWithdrawDeposite(final Agent agent, final LocalDate transactionDate, + final JsonCommand command) { + final String creditorPartyCode = command.stringValueOfParameterNamed("creditorPartyCode"); + final String debitPartyCode = command.stringValueOfParameterNamed("debitPartyCode"); + final BigDecimal amount = command.bigDecimalValueOfParameterNamed("withdrawAmount"); + final String description = command.stringValueOfParameterNamedAllowingNull("withdrawDescription"); + + // Money moved from an account into itself is no transfer at all - just two lines that cancel. + if (creditorPartyCode != null && creditorPartyCode.trim().equalsIgnoreCase(String.valueOf(debitPartyCode).trim())) { + final String message = "Pick two different accounts - " + creditorPartyCode.trim().toUpperCase() + + " cannot transfer to itself."; + throw new PlatformApiDataValidationException("error.msg.dds.transfer.same.party", message, + List.of(ApiParameterError.generalError("error.msg.dds.transfer.same.party", message))); + } + + final Party creditorParty = partyOf(creditorPartyCode, agent); + final Party debitParty = partyOf(debitPartyCode, agent); + + // Held money cannot leave by this door either. A transfer to another party is the third way + // out of an account - beside the maturity payout and an early closure, both of which already + // refuse it - and leaving it open would let a held deposit be moved to an account that is + // not held, which is the lien undone in one step. + if (debitParty.isLien()) { + throw new PartyHeldUnderLienException(debitParty.getPartyCode(), debitParty.getLienReason()); + } + + if (debitParty.getCurrentBalance().compareTo(amount) < 0) { + throw new InsufficientPartyBalanceException(debitPartyCode); + } + + final BigDecimal debtorNewBalance = debitParty.debit(amount); + final BigDecimal creditorNewBalance = creditorParty.credit(amount); + + // The same move on the bank's own books, wherever each party's account is kept. Two DDS + // accounts are both owed on DDS Customer Deposits, so between them nothing changes account. + this.ddsLedger.transfer(debitParty, creditorParty, transactionDate, amount, + description == null ? "DDS transfer " + debitPartyCode + " to " + creditorPartyCode : description); + + // Each side's passbook line says where the money went or came from - a line reading only + // "Transfer" leaves the customer asking whose account took their money. + final String note = description == null || description.isBlank() ? "" : " - " + description; + final List transactions = new ArrayList<>(); + transactions.add(new PartyTransaction(debitParty, agent, transactionDate, "WITHDRAW_DEPOSITE", "DEBIT", null, "DDS", + creditorParty.getAccountNo(), amount, null, null, + "Transfer to " + creditorPartyCode + " · " + creditorParty.getPartyName() + note, debtorNewBalance)); + transactions.add(new PartyTransaction(creditorParty, agent, transactionDate, "WITHDRAW_DEPOSITE", "CREDIT", null, "DDS", + debitParty.getAccountNo(), amount, null, null, + "Transfer from " + debitPartyCode + " · " + debitParty.getPartyName() + note, creditorNewBalance)); + return transactions; + } + + /** + * Finds the party a transaction names, and refuses it unless this agent actually collects for + * them and the account is still open. + * + * The screen keeps a clerk to the right parties by only offering that agent's own in the + * dropdown, but Withdraw Deposite takes party codes as free text and the API can be called + * directly either way - so the rule belongs here, where nothing can go around it. + */ + private Party partyOf(final String partyCode, final Agent agent) { + final Party party = this.partyRepository.findByPartyCode(partyCode) + .orElseThrow(() -> new PartyNotFoundException(partyCode)); + if (party.isClosed()) { + throw new PartyAlreadyClosedException(partyCode); + } + if (!party.getAgent().getId().equals(agent.getId())) { + throw new PartyNotCollectedByAgentException(partyCode, agent.getAgentCode()); + } + return party; + } + + private String stringOrNull(final JsonObject json, final String paramName) { + return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsString() : null; + } + + private void handleDataIntegrityIssues(final Throwable realCause, final Exception dve) { + if (realCause.getMessage() != null && realCause.getMessage().contains("party_code")) { + throw new PlatformDataIntegrityException("error.msg.partytransaction.invalid.party", + "One of the party codes in this transaction does not exist"); + } + + log.error("Error occured.", dve); + throw ErrorHandler.getMappable(dve, "error.msg.partytransaction.unknown.data.integrity.issue", + "Unknown data integrity issue with resource: " + realCause.getMessage()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/starter/DdsPartyTransactionConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/starter/DdsPartyTransactionConfiguration.java new file mode 100644 index 000000000..4e75059bc --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/starter/DdsPartyTransactionConfiguration.java @@ -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.starter; + +import org.apache.fineract.organisation.agent.domain.AgentRepository; +import org.apache.fineract.organisation.workingdays.domain.WorkingDaysRepositoryWrapper; +import org.apache.fineract.portfolio.dds.accounting.DdsLedger; +import org.apache.fineract.portfolio.dds.party.domain.PartyRepository; +import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository; +import org.apache.fineract.portfolio.dds.transaction.serialization.PartyTransactionCommandFromApiJsonDeserializer; +import org.apache.fineract.portfolio.dds.transaction.service.PartyTransactionReadPlatformService; +import org.apache.fineract.portfolio.dds.transaction.service.PartyTransactionReadPlatformServiceImpl; +import org.apache.fineract.portfolio.dds.transaction.service.PartyTransactionWritePlatformService; +import org.apache.fineract.portfolio.dds.transaction.service.PartyTransactionWritePlatformServiceJpaRepositoryImpl; +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 DdsPartyTransactionConfiguration { + + @Bean + @ConditionalOnMissingBean(PartyTransactionReadPlatformService.class) + public PartyTransactionReadPlatformService partyTransactionReadPlatformService(JdbcTemplate jdbcTemplate) { + return new PartyTransactionReadPlatformServiceImpl(jdbcTemplate); + } + + @Bean + @ConditionalOnMissingBean(PartyTransactionWritePlatformService.class) + public PartyTransactionWritePlatformService partyTransactionWritePlatformService( + PartyTransactionCommandFromApiJsonDeserializer fromApiJsonDeserializer, PartyTransactionRepository partyTransactionRepository, + PartyRepository partyRepository, AgentRepository agentRepository, DdsLedger ddsLedger, + WorkingDaysRepositoryWrapper workingDaysRepository) { + return new PartyTransactionWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, partyTransactionRepository, + partyRepository, agentRepository, ddsLedger, workingDaysRepository); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java index e63446472..79978ccc7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java @@ -358,6 +358,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead sqlBuilder.append("sp.last_interest_date as lastInterestDate, sp.enable_lockin_period as enableLockinPeriod, "); sqlBuilder.append("sp.balance_frequency as balanceFrequency, "); sqlBuilder.append("sp.account_closed_zero_balance_in_day_closing as accountClosedZeroBalanceInDayClosing, "); + sqlBuilder.append("sp.product_category as productCategory, "); sqlBuilder.append("sp.auto_debit_facility as autoDebitFacility, sp.loan_facility as loanFacility, "); sqlBuilder.append("sp.premature_withdrawal_option as prematureWithdrawalOption, sp.tenure_flexibility as tenureFlexibility, "); sqlBuilder.append("sp.lien_marked as lienMarked, sp.maturity_instructions as maturityInstructions, "); @@ -447,6 +448,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead result.setEnableLockinPeriod(rs.getBoolean("enableLockinPeriod")); result.setBalanceFrequency(rs.getString("balanceFrequency")); result.setAccountClosedZeroBalanceInDayClosing(rs.getBoolean("accountClosedZeroBalanceInDayClosing")); + result.setProductCategory(rs.getString("productCategory")); result.setAutoDebitFacility(rs.getBoolean("autoDebitFacility")); result.setLoanFacility(rs.getBoolean("loanFacility")); result.setPrematureWithdrawalOption(rs.getBoolean("prematureWithdrawalOption")); @@ -498,6 +500,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead sqlBuilder.append("sp.last_interest_date as lastInterestDate, sp.enable_lockin_period as enableLockinPeriod, "); sqlBuilder.append("sp.balance_frequency as balanceFrequency, "); sqlBuilder.append("sp.account_closed_zero_balance_in_day_closing as accountClosedZeroBalanceInDayClosing, "); + sqlBuilder.append("sp.product_category as productCategory, "); sqlBuilder.append("sp.balance_swap_option as balanceSwapOption, sp.msme as msme, "); sqlBuilder.append("sp.lien_percentage as lienPercentage, "); sqlBuilder.append("sp.missed_installment_grace_days as missedInstallmentGraceDays, "); @@ -578,6 +581,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead final boolean enableLockinPeriod = rs.getBoolean("enableLockinPeriod"); final String balanceFrequency = rs.getString("balanceFrequency"); final boolean accountClosedZeroBalanceInDayClosing = rs.getBoolean("accountClosedZeroBalanceInDayClosing"); + final String productCategory = rs.getString("productCategory"); final boolean balanceSwapOption = rs.getBoolean("balanceSwapOption"); final boolean msme = rs.getBoolean("msme"); final boolean autoDebitFacility = rs.getBoolean("autoDebitFacility"); @@ -620,6 +624,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead result.setEnableLockinPeriod(enableLockinPeriod); result.setBalanceFrequency(balanceFrequency); result.setAccountClosedZeroBalanceInDayClosing(accountClosedZeroBalanceInDayClosing); + result.setProductCategory(productCategory); result.setBalanceSwapOption(balanceSwapOption); result.setMsme(msme); result.setAutoDebitFacility(autoDebitFacility); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductReadPlatformServiceImpl.java index fc1ffe061..d57b76adf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductReadPlatformServiceImpl.java @@ -287,6 +287,7 @@ public class SavingsProductReadPlatformServiceImpl implements SavingsProductRead sqlBuilder.append("sp.enable_lockin_period as enableLockinPeriod, "); sqlBuilder.append("sp.balance_frequency as balanceFrequency, "); sqlBuilder.append("sp.account_closed_zero_balance_in_day_closing as accountClosedZeroBalanceInDayClosing, "); + sqlBuilder.append("sp.product_category as productCategory, "); sqlBuilder.append("exists(select 1 from m_saving_product_temp spt where spt.product_id = sp.id " + "and spt.product_type = 'SAVINGS' and spt.status = 'PENDING') as isPendingApproval "); @@ -411,6 +412,7 @@ public class SavingsProductReadPlatformServiceImpl implements SavingsProductRead productData.setEnableLockinPeriod(rs.getBoolean("enableLockinPeriod")); productData.setBalanceFrequency(rs.getString("balanceFrequency")); productData.setAccountClosedZeroBalanceInDayClosing(rs.getBoolean("accountClosedZeroBalanceInDayClosing")); + productData.setProductCategory(rs.getString("productCategory")); productData.setIsPendingApproval(rs.getBoolean("isPendingApproval")); return productData; diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index b77490e94..fc23098bb 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -615,4 +615,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_add_product_category_to_savings_product.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_add_product_category_to_savings_product.xml new file mode 100644 index 000000000..3f887696b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_add_product_category_to_savings_product.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_create_dds_agent.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_create_dds_agent.xml new file mode 100644 index 000000000..9675856b5 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_create_dds_agent.xml @@ -0,0 +1,230 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0473_create_dds_party.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0473_create_dds_party.xml new file mode 100644 index 000000000..680c5dcfb --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0473_create_dds_party.xml @@ -0,0 +1,267 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_create_dds_party_transaction.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_create_dds_party_transaction.xml new file mode 100644 index 000000000..dafb115d4 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_create_dds_party_transaction.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_create_dds_commission_slab.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_create_dds_commission_slab.xml new file mode 100644 index 000000000..faad135a9 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_create_dds_commission_slab.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_create_dds_batch_run.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_create_dds_batch_run.xml new file mode 100644 index 000000000..30fe00e92 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_create_dds_batch_run.xml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0477_add_dds_report_permission.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0477_add_dds_report_permission.xml new file mode 100644 index 000000000..847ef16fa --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0477_add_dds_report_permission.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0478_create_dds_account_type.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0478_create_dds_account_type.xml new file mode 100644 index 000000000..a10f130a5 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0478_create_dds_account_type.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0479_dds_account_type_delete_and_cleanup.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0479_dds_account_type_delete_and_cleanup.xml new file mode 100644 index 000000000..88f9c4c6e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0479_dds_account_type_delete_and_cleanup.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0480_restore_dds_account_type_post_day.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0480_restore_dds_account_type_post_day.xml new file mode 100644 index 000000000..0c15fa895 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0480_restore_dds_account_type_post_day.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0481_trim_dds_agent_to_legacy_fields.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0481_trim_dds_agent_to_legacy_fields.xml new file mode 100644 index 000000000..8434e9922 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0481_trim_dds_agent_to_legacy_fields.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0482_add_dds_agent_audit_columns.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0482_add_dds_agent_audit_columns.xml new file mode 100644 index 000000000..81f658b83 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0482_add_dds_agent_audit_columns.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + update m_dds_agent set + created_date = (select min(c.made_on_date) from m_portfolio_command_source c + where c.entity_name = 'AGENT' and c.action_name = 'CREATE' + and c.resource_id = m_dds_agent.id), + createdby_id = (select min(c.maker_id) from m_portfolio_command_source c + where c.entity_name = 'AGENT' and c.action_name = 'CREATE' + and c.resource_id = m_dds_agent.id) + where created_date is null + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0483_add_dds_agent_is_active.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0483_add_dds_agent_is_active.xml new file mode 100644 index 000000000..d828e3ced --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0483_add_dds_agent_is_active.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0484_backfill_dds_agent_created_date_utc.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0484_backfill_dds_agent_created_date_utc.xml new file mode 100644 index 000000000..a143d130e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0484_backfill_dds_agent_created_date_utc.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + update m_dds_agent set + created_date = (select min(coalesce(c.made_on_date, c.made_on_date_utc)) + from m_portfolio_command_source c + where c.entity_name = 'AGENT' and c.action_name = 'CREATE' + and c.resource_id = m_dds_agent.id), + createdby_id = (select min(c.maker_id) from m_portfolio_command_source c + where c.entity_name = 'AGENT' and c.action_name = 'CREATE' + and c.resource_id = m_dds_agent.id) + where created_date is null + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0485_add_commission_slab_update_permission.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0485_add_commission_slab_update_permission.xml new file mode 100644 index 000000000..109ce88fa --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0485_add_commission_slab_update_permission.xml @@ -0,0 +1,57 @@ + + + + + + + + + select count(*) from m_permission where code = 'UPDATE_COMMISSIONSLAB' + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0486_add_dds_audit_columns.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0486_add_dds_audit_columns.xml new file mode 100644 index 000000000..2417b85c8 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0486_add_dds_audit_columns.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0487_add_dds_stage6_columns.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0487_add_dds_stage6_columns.xml new file mode 100644 index 000000000..7016ebfc9 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0487_add_dds_stage6_columns.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0488_add_dds_auto_interest_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0488_add_dds_auto_interest_job.xml new file mode 100644 index 000000000..d07351ebb --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0488_add_dds_auto_interest_job.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + auto_interest_post = true and auto_interest_post_day is null + + + + + + + select count(*) from job where name = 'Post DDS Interest' + + + + insert into job (id, name, display_name, short_name, cron_expression, create_time, task_priority, + job_key, is_active, currently_running, updates_allowed, scheduler_group, + is_misfired, node_id, is_mismatched_job) + select coalesce(max(id), 0) + 1, 'Post DDS Interest', 'Post DDS Interest', 'DDS_PINT', + '0 20 0 1/1 * ? *', now(), 5, 'Post DDS InterestJobDetail1 _ DEFAULT', + true, false, true, 1, false, 1, true + from job; + + + select setval(pg_get_serial_sequence('job', 'id'), (select max(id) from job)); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0489_add_dds_agent_close.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0489_add_dds_agent_close.xml new file mode 100644 index 000000000..80fa31699 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0489_add_dds_agent_close.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + select count(*) from m_permission where code = 'CLOSE_AGENT' + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0490_add_dds_agent_office.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0490_add_dds_agent_office.xml new file mode 100644 index 000000000..3b8ae9101 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0490_add_dds_agent_office.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + select count(*) from m_dds_agent where office_id is not null + + + update m_dds_agent a set office_id = o.id + from m_office o + where o.name = a.home_branch and a.office_id is null; + + + update m_dds_agent a set office_id = c.office_id + from m_client c + where c.id = a.client_id and a.office_id is null; + + + + update m_dds_agent a set home_branch = o.name + from m_office o + where o.id = a.office_id; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0491_add_dds_commission_expense_account.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0491_add_dds_commission_expense_account.xml new file mode 100644 index 000000000..1bd82bf86 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0491_add_dds_commission_expense_account.xml @@ -0,0 +1,70 @@ + + + + + + + + select count(*) from acc_gl_account where gl_code = '11.2' + + + insert into acc_gl_account (name, parent_id, hierarchy, gl_code, disabled, manual_journal_entries_allowed, + account_usage, classification_enum, description) + select 'DDS Agent Commission Expense', + (select id from acc_gl_account where gl_code = '11' limit 1), + '.', '11.2', false, true, 1, 5, + 'Commission earned by DDS collection agents, credited to their own savings account when a commission run posts.'; + + + + + + + select count(*) from c_configuration where name = 'dds-agent-commission-expense-account' + + + + + insert into c_configuration (name, value, enabled, is_trap_door, description) + select 'dds-agent-commission-expense-account', + (select id from acc_gl_account where gl_code = '11.2' limit 1), + true, false, + 'GL account debited when a DDS commission run pays an agent. The matching credit goes to the agent''s savings account.'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0492_add_dds_deposit_product_setting.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0492_add_dds_deposit_product_setting.xml new file mode 100644 index 000000000..9973fc84f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0492_add_dds_deposit_product_setting.xml @@ -0,0 +1,56 @@ + + + + + + + + select count(*) from c_configuration where name = 'dds-deposit-product' + + + insert into c_configuration (name, value, enabled, is_trap_door, description) + select 'dds-deposit-product', + (select id from m_savings_product where short_name = 'DDS1' limit 1), + true, false, + 'Savings product a DDS party''s real account is opened under. Must be a savings product, not a fixed or recurring deposit.'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0493_add_dds_agent_cash_account.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0493_add_dds_agent_cash_account.xml new file mode 100644 index 000000000..6cdc7cc13 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0493_add_dds_agent_cash_account.xml @@ -0,0 +1,63 @@ + + + + + + + + select count(*) from acc_gl_account where gl_code = '2.1' + + + insert into acc_gl_account (name, parent_id, hierarchy, gl_code, disabled, manual_journal_entries_allowed, + account_usage, classification_enum, description) + values ('DDS Agent Cash', null, '.', '2.1', false, true, 1, 1, + 'Cash collected by DDS agents in the field and not yet handed over to a branch. The bank owns it, but it is in the agent bag rather than a till or vault. Cleared when the agent settles at a branch.'); + + + + + + select count(*) from c_configuration where name = 'dds-agent-cash-account' + + + insert into c_configuration (name, value, enabled, is_trap_door, description) + select 'dds-agent-cash-account', + (select id from acc_gl_account where gl_code = '2.1' limit 1), + true, false, + 'GL account holding cash DDS agents have collected but not yet settled at a branch. Debited on collection, credited when the agent settles.'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0494_add_dds_matured_payable_account.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0494_add_dds_matured_payable_account.xml new file mode 100644 index 000000000..22b682e25 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0494_add_dds_matured_payable_account.xml @@ -0,0 +1,91 @@ + + + + + + + + select count(*) from acc_gl_account where gl_code = '9.6' + + + insert into acc_gl_account (name, parent_id, hierarchy, gl_code, disabled, manual_journal_entries_allowed, + account_usage, classification_enum, description) + select 'DDS Matured Deposits Payable', + (select id from acc_gl_account where gl_code = '9' limit 1), + '.', '9.6', false, true, 1, 2, + 'Matured DDS deposits taken off the customer account and owed to them, waiting to be paid out to an account at another bank. Cleared when the transfer actually leaves.'; + + + + + + + select count(*) from c_configuration where name = 'dds-matured-payable-account' + + + + insert into c_configuration (name, value, enabled, is_trap_door, description) + select 'dds-matured-payable-account', + (select id from acc_gl_account where gl_code = '9.6' limit 1), + true, false, + 'GL account holding matured DDS deposits that are owed to a customer but not yet transferred out to another bank.'; + + + + + + + select count(*) from m_permission where code = 'PAYOUT_PARTY' + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0495_dds_collection_entry_no_maker_checker.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0495_dds_collection_entry_no_maker_checker.xml new file mode 100644 index 000000000..b8dbd797f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0495_dds_collection_entry_no_maker_checker.xml @@ -0,0 +1,59 @@ + + + + + + + + + select count(*) from m_permission + where code = 'CREATE_PARTYTRANSACTION' and can_maker_checker = true + + + + update m_permission set can_maker_checker = false where code = 'CREATE_PARTYTRANSACTION'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0496_grant_dds_permissions_to_branch_manager.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0496_grant_dds_permissions_to_branch_manager.xml new file mode 100644 index 000000000..96bc62a2e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0496_grant_dds_permissions_to_branch_manager.xml @@ -0,0 +1,70 @@ + + + + + + + + select count(*) from m_role where name = 'Branch Manager' + + + insert into m_role_permission (role_id, permission_id) + select r.id, p.id + from m_role r + join m_permission p + on p.code like '%PARTY%' + or p.code like '%AGENT%' + or p.code like '%DDS%' + or p.code like '%COMMISSIONSLAB%' + where r.name = 'Branch Manager' + and not exists (select 1 + from m_role_permission rp + where rp.role_id = r.id + and rp.permission_id = p.id); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0497_backfill_dds_maturity_payout_ledger_rows.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0497_backfill_dds_maturity_payout_ledger_rows.xml new file mode 100644 index 000000000..5757b3171 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0497_backfill_dds_maturity_payout_ledger_rows.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + insert into m_dds_party_transaction + (party_id, agent_id, transaction_date, transaction_type, direction, amount, + description, balance_after_transaction) + select p.id, + p.agent_id, + je.entry_date, + 'MATURITY_PAYOUT', + 'DEBIT', + je.amount, + 'Maturity payout, reconstructed from the journal entry', + 0 + from m_dds_party p + join acc_gl_journal_entry je + on je.description = 'DDS maturity payout for ' || p.party_code + and je.type_enum = 2 + and je.reversed = false + where not exists (select 1 + from m_dds_party_transaction t + where t.party_id = p.id + and t.transaction_type = 'MATURITY_PAYOUT'); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0498_block_debits_on_dds_accounts.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0498_block_debits_on_dds_accounts.xml new file mode 100644 index 000000000..710c64349 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0498_block_debits_on_dds_accounts.xml @@ -0,0 +1,62 @@ + + + + + + + + + select count(*) from c_configuration where name = 'dds-deposit-product' and enabled = true + + + + update m_savings_account sa + set sub_status_enum = 600 + where sa.status_enum = 300 + and sa.sub_status_enum = 0 + and sa.product_id = (select cast(value as bigint) from c_configuration + where name = 'dds-deposit-product' and enabled = true) + and exists (select 1 from m_dds_party p + where p.savings_account_id = sa.id + and p.status = 'ACTIVE'); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0499_add_dds_batch_run_progress.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0499_add_dds_batch_run_progress.xml new file mode 100644 index 000000000..dfc05942e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0499_add_dds_batch_run_progress.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0500_allow_lien_on_dds_product.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0500_allow_lien_on_dds_product.xml new file mode 100644 index 000000000..3489c9f75 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0500_allow_lien_on_dds_product.xml @@ -0,0 +1,56 @@ + + + + + + + + + select count(*) from c_configuration where name = 'dds-deposit-product' and enabled = true + + + + update m_savings_product + set is_lien_allowed = true + where id = (select cast(value as bigint) from c_configuration + where name = 'dds-deposit-product' and enabled = true); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0501_fix_dds_lien_limits.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0501_fix_dds_lien_limits.xml new file mode 100644 index 000000000..a7f810104 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0501_fix_dds_lien_limits.xml @@ -0,0 +1,71 @@ + + + + + + + + + select count(*) from c_configuration where name = 'dds-deposit-product' and enabled = true + + + + + update m_savings_product + set max_allowed_lien_limit = 10000000 + where id = (select cast(value as bigint) from c_configuration + where name = 'dds-deposit-product' and enabled = true); + + + + update m_savings_account + set is_lien_allowed = true, + max_allowed_lien_limit = 10000000 + where product_id = (select cast(value as bigint) from c_configuration + where name = 'dds-deposit-product' and enabled = true); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0502_add_dds_premature_closure_settings.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0502_add_dds_premature_closure_settings.xml new file mode 100644 index 000000000..21c99e3df --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0502_add_dds_premature_closure_settings.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0503_add_dds_premature_close_permission.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0503_add_dds_premature_close_permission.xml new file mode 100644 index 000000000..e2e9425c0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0503_add_dds_premature_close_permission.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + insert into m_role_permission (role_id, permission_id) + select r.id, p.id + from m_role r, m_permission p + where r.name = 'Branch Manager' + and p.code in ('PREMATURECLOSE_PARTY', 'PREMATURECLOSE_PARTY_CHECKER') + and not exists (select 1 from m_role_permission rp + where rp.role_id = r.id and rp.permission_id = p.id); + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0504_add_dds_nominee_share.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0504_add_dds_nominee_share.xml new file mode 100644 index 000000000..2dc441136 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0504_add_dds_nominee_share.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0505_add_dds_nominee_sequence.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0505_add_dds_nominee_sequence.xml new file mode 100644 index 000000000..571dcb67f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0505_add_dds_nominee_sequence.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0506_add_dds_child_audit_columns.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0506_add_dds_child_audit_columns.xml new file mode 100644 index 000000000..0343901ce --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0506_add_dds_child_audit_columns.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0507_add_dds_party_office.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0507_add_dds_party_office.xml new file mode 100644 index 000000000..807433ee6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0507_add_dds_party_office.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + office_id is null + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0508_add_dds_party_lien_reason.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0508_add_dds_party_lien_reason.xml new file mode 100644 index 000000000..fce75297a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0508_add_dds_party_lien_reason.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0509_normalise_dds_transaction_mode.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0509_normalise_dds_transaction_mode.xml new file mode 100644 index 000000000..8a8c63543 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0509_normalise_dds_transaction_mode.xml @@ -0,0 +1,74 @@ + + + + + + + + + + update m_dds_party_transaction set transaction_mode = 'Cash' + where transaction_mode is not null + and upper(trim(transaction_mode)) = 'CASH' + and transaction_mode <> 'Cash'; + + + + update m_dds_party_transaction set transaction_mode = 'Transfer' + where transaction_mode is not null + and upper(trim(transaction_mode)) = 'TRANSFER' + and transaction_mode <> 'Transfer'; + + + + update m_dds_party_transaction set transaction_mode = 'Clearing' + where transaction_mode is not null + and upper(trim(transaction_mode)) = 'CLEARING' + and transaction_mode <> 'Clearing'; + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0510_add_dds_maturity_auto_close.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0510_add_dds_maturity_auto_close.xml new file mode 100644 index 000000000..6a6bd71b1 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0510_add_dds_maturity_auto_close.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0511_create_dds_collection_entry.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0511_create_dds_collection_entry.xml new file mode 100644 index 000000000..749351e60 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0511_create_dds_collection_entry.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0512_dds_collection_entry_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0512_dds_collection_entry_permissions.xml new file mode 100644 index 000000000..2d843093c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0512_dds_collection_entry_permissions.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0513_add_dds_agent_gl_account.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0513_add_dds_agent_gl_account.xml new file mode 100644 index 000000000..7c347857c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0513_add_dds_agent_gl_account.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0514_add_dds_party_account_no.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0514_add_dds_party_account_no.xml new file mode 100644 index 000000000..eb8b38ae8 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0514_add_dds_party_account_no.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + update m_dds_party set account_no = 'DDS' || lpad(cast(id as varchar), 9, '0') where account_no is null; + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0515_add_dds_gl_entry_type.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0515_add_dds_gl_entry_type.xml new file mode 100644 index 000000000..cfc5a152f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0515_add_dds_gl_entry_type.xml @@ -0,0 +1,50 @@ + + + + + + + + INSERT INTO m_code_value (code_id, code_value, order_position, is_active, is_mandatory) + SELECT c.id, 'DDS', 4, true, false + FROM m_code c + WHERE c.code_name = 'GLEntryType' + AND NOT EXISTS ( + SELECT 1 FROM m_code_value cv + WHERE cv.code_id = c.id AND cv.code_value = 'DDS' + ); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0516_add_dds_cash_receipt_apply.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0516_add_dds_cash_receipt_apply.xml new file mode 100644 index 000000000..425482db5 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0516_add_dds_cash_receipt_apply.xml @@ -0,0 +1,76 @@ + + + + + + + + + + select count(*) from acc_gl_account where gl_code = '9.7' + + + insert into acc_gl_account (name, parent_id, hierarchy, gl_code, disabled, manual_journal_entries_allowed, + account_usage, classification_enum, description) + values ('DDS Customer Deposits', null, '.', '9.7', false, true, 1, 2, + 'What the bank owes DDS customers on their DDS accounts. Credited when an agent''s collection is applied to the customers it was collected from, debited when an account is paid out.'); + + + + + + select count(*) from c_configuration where name = 'dds-deposit-liability-account' + + + insert into c_configuration (name, value, enabled, is_trap_door, description) + select 'dds-deposit-liability-account', + (select id from acc_gl_account where gl_code = '9.7' limit 1), + true, false, + 'GL account holding what DDS customers are owed on their DDS accounts. Credited when a cashier''s DDS receipt is applied to the agent''s collections.'; + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_add_dds_receipt_application.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_add_dds_receipt_application.xml new file mode 100644 index 000000000..108ab25a5 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_add_dds_receipt_application.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_add_dds_interest_expense_account.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_add_dds_interest_expense_account.xml new file mode 100644 index 000000000..5d0885dbb --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_add_dds_interest_expense_account.xml @@ -0,0 +1,59 @@ + + + + + + + + select count(*) from acc_gl_account where gl_code = '11.3' + + + insert into acc_gl_account (name, parent_id, hierarchy, gl_code, disabled, manual_journal_entries_allowed, + account_usage, classification_enum, description) + values ('DDS Interest Expense', null, '.', '11.3', false, true, 1, 5, + 'Interest paid to DDS customers on their DDS accounts. Debited when interest is posted, credited back when interest is recovered on an early closure.'); + + + + + + select count(*) from c_configuration where name = 'dds-interest-expense-account' + + + insert into c_configuration (name, value, enabled, is_trap_door, description) + select 'dds-interest-expense-account', + (select id from acc_gl_account where gl_code = '11.3' limit 1), + true, false, + 'GL account for what interest on DDS accounts costs the bank. Debited when DDS interest is posted.'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0519_dds_current_account_payout_and_transaction_checker.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0519_dds_current_account_payout_and_transaction_checker.xml new file mode 100644 index 000000000..1beb61c59 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0519_dds_current_account_payout_and_transaction_checker.xml @@ -0,0 +1,59 @@ + + + + + + + + select count(*) from c_configuration where name = 'dds-current-account-control-account' + + + insert into c_configuration (name, value, enabled, is_trap_door, description) + values ('dds-current-account-control-account', null, true, false, + 'GL control account a DDS maturity paid into a current account is credited to. Only needed when the current product''s GL mapping cannot be told apart from a savings product''s with the same id.'); + + + + + + + update m_permission set can_maker_checker = true where code = 'CREATE_PARTYTRANSACTION'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0520_add_dds_collection_entry_days.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0520_add_dds_collection_entry_days.xml new file mode 100644 index 000000000..85c33d937 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0520_add_dds_collection_entry_days.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0521_add_dds_premature_close_override_permission.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0521_add_dds_premature_close_override_permission.xml new file mode 100644 index 000000000..aacccb898 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0521_add_dds_premature_close_override_permission.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + insert into m_role_permission (role_id, permission_id) + select r.id, p.id + from m_role r, m_permission p + where r.name = 'Branch Manager' + and p.code in ('PREMATURECLOSEOVERRIDE_PARTY', 'PREMATURECLOSEOVERRIDE_PARTY_CHECKER') + and not exists (select 1 from m_role_permission rp + where rp.role_id = r.id and rp.permission_id = p.id); + + + + + + + + insert into m_role_permission (role_id, permission_id) + select r.id, p.id + from m_role r, m_permission p + where r.name = 'Branch Manager' + and p.code in ('READ_DDSCOLLECTIONENTRY', 'SETTLE_DDSCOLLECTIONENTRY', 'SETTLE_DDSCOLLECTIONENTRY_CHECKER') + and not exists (select 1 from m_role_permission rp + where rp.role_id = r.id and rp.permission_id = p.id); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0522_add_dds_party_transaction_commission_run.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0522_add_dds_party_transaction_commission_run.xml new file mode 100644 index 000000000..4f1f3fd07 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0522_add_dds_party_transaction_commission_run.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + update m_dds_party_transaction t + set commission_batch_run_id = coalesce( + (select max(b.id) + from m_dds_batch_run b + join m_dds_batch_run_row r on r.batch_run_id = b.id + where b.job_type = 'COMMISSION_CALCULATION' and b.status = 'POSTED' and r.agent_id = t.agent_id), 0) + where t.transaction_type = 'PARTY_TRAN' + and t.direction = 'CREDIT' + and t.transaction_date <= (select a.last_commission_date from m_dds_agent a where a.id = t.agent_id); + + + + + + + + + + + + update m_dds_party_transaction t + set commission_batch_run_id = null + where t.commission_batch_run_id > 0 + and t.created_date > (select b.lastmodified_date from m_dds_batch_run b where b.id = t.commission_batch_run_id); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0523_add_dds_account_type_interest_rate.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0523_add_dds_account_type_interest_rate.xml new file mode 100644 index 000000000..5e3c2b8c3 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0523_add_dds_account_type_interest_rate.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + update m_dds_account_type t + set interest_rate = coalesce( + (select p.interest_rate + from m_dds_party p + where upper(trim(p.sub_type)) = upper(trim(t.account_type_code)) + and p.interest_rate is not null + group by p.interest_rate + order by count(*) desc, p.interest_rate + limit 1), 0); + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0524_dds_park_in_sundry_creditors.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0524_dds_park_in_sundry_creditors.xml new file mode 100644 index 000000000..e66b12efd --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0524_dds_park_in_sundry_creditors.xml @@ -0,0 +1,55 @@ + + + + + + + + update c_configuration + set value = (select id from acc_gl_account where gl_code = '200902') + where name = 'dds-matured-payable-account' + and exists (select 1 from acc_gl_account where gl_code = '200902') + and not exists (select 1 from acc_gl_journal_entry j join acc_gl_account g on g.id = j.account_id + where g.gl_code = '9.6'); + + + + + + delete from acc_gl_account g + where g.gl_code = '9.6' + and not exists (select 1 from acc_gl_journal_entry j where j.account_id = g.id) + and not exists (select 1 from acc_gl_account child where child.parent_id = g.id) + and not exists (select 1 from c_configuration c where c.name = 'dds-matured-payable-account' and c.value = g.id); + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_add_dds_party_transaction_voucher_no.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_add_dds_party_transaction_voucher_no.xml new file mode 100644 index 000000000..e29460a51 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_add_dds_party_transaction_voucher_no.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java index 1909f9190..c9b3fa285 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java @@ -498,6 +498,7 @@ public final class FixedDepositProductData extends DepositProductData { private Boolean allowDebitTxnAfterDueDate; private Boolean accountClosedZeroBalanceInDayClosing; private String balanceFrequency; + private String productCategory; private Boolean isPendingApproval; private Integer renewalGracePeriodDays; @@ -546,6 +547,7 @@ public final class FixedDepositProductData extends DepositProductData { public void setAllowDebitTxnAfterDueDate(Boolean v) { this.allowDebitTxnAfterDueDate = v; } public void setAccountClosedZeroBalanceInDayClosing(Boolean v) { this.accountClosedZeroBalanceInDayClosing = v; } public void setBalanceFrequency(String v) { this.balanceFrequency = v; } + public void setProductCategory(String v) { this.productCategory = v; } public void setIsPendingApproval(Boolean v) { this.isPendingApproval = v; } public void setRenewalGracePeriodDays(Integer v) { this.renewalGracePeriodDays = v; } @@ -610,6 +612,7 @@ public final class FixedDepositProductData extends DepositProductData { this.allowDebitTxnAfterDueDate = src.allowDebitTxnAfterDueDate; this.accountClosedZeroBalanceInDayClosing = src.accountClosedZeroBalanceInDayClosing; this.balanceFrequency = src.balanceFrequency; + this.productCategory = src.productCategory; this.withdrawalFeeForTransfers = src.withdrawalFeeForTransfers; this.renewalGracePeriodDays = src.renewalGracePeriodDays; this.setOtherGlMappings(src.getOtherGlMappings()); diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositProductData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositProductData.java index 839b639ab..38c0ee380 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositProductData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositProductData.java @@ -83,6 +83,7 @@ public final class RecurringDepositProductData extends DepositProductData { private Boolean enableLockinPeriod; private String balanceFrequency; private Boolean accountClosedZeroBalanceInDayClosing; + private String productCategory; private Boolean balanceSwapOption; private Boolean msme; private Boolean autoDebitFacility; @@ -130,6 +131,7 @@ public final class RecurringDepositProductData extends DepositProductData { public void setEnableLockinPeriod(Boolean v) { this.enableLockinPeriod = v; } public void setBalanceFrequency(String v) { this.balanceFrequency = v; } public void setAccountClosedZeroBalanceInDayClosing(Boolean v) { this.accountClosedZeroBalanceInDayClosing = v; } + public void setProductCategory(String v) { this.productCategory = v; } public void setBalanceSwapOption(Boolean v) { this.balanceSwapOption = v; } public void setMsme(Boolean v) { this.msme = v; } public void setAutoDebitFacility(Boolean v) { this.autoDebitFacility = v; } @@ -177,6 +179,7 @@ public final class RecurringDepositProductData extends DepositProductData { this.enableLockinPeriod = src.enableLockinPeriod; this.balanceFrequency = src.balanceFrequency; this.accountClosedZeroBalanceInDayClosing = src.accountClosedZeroBalanceInDayClosing; + this.productCategory = src.productCategory; this.balanceSwapOption = src.balanceSwapOption; this.msme = src.msme; this.autoDebitFacility = src.autoDebitFacility; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductDataValidator.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductDataValidator.java index 8ee4f3417..754e375cb 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductDataValidator.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductDataValidator.java @@ -125,7 +125,7 @@ public class SavingsProductDataValidator { SavingsApiConstants.depositLimitKycParamName, // Additional custom fields SavingsApiConstants.balanceFrequencyParamName, SavingsApiConstants.accountClosedZeroBalanceInDayClosingParamName, - SavingsApiConstants.glAccountIdParamName)); + SavingsApiConstants.glAccountIdParamName, SavingsApiConstants.productCategoryParamName)); public void validateForCreate(final String json) { diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java index 31b9e7a26..9a48bea27 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java @@ -297,6 +297,9 @@ public class SavingsProduct extends AbstractPersistableCustom { @Column(name = "account_closed_zero_balance_in_day_closing") private Boolean accountClosedZeroBalanceInDayClosing = false; + @Column(name = "product_category", length = 50) + private String productCategory; + // RD-specific flags @Column(name = "balance_swap_option") private boolean balanceSwapOption = false; @@ -912,6 +915,13 @@ public class SavingsProduct extends AbstractPersistableCustom { this.accountClosedZeroBalanceInDayClosing = newValue; } + // Update productCategory + if (command.isChangeInStringParameterNamed("productCategory", this.productCategory)) { + final String newValue = command.stringValueOfParameterNamed("productCategory"); + actualChanges.put("productCategory", newValue); + this.productCategory = newValue; + } + // Update RD-specific flags (0270 migration fields) if (command.isChangeInBooleanParameterNamed("balanceSwapOption", this.balanceSwapOption)) { this.balanceSwapOption = command.booleanPrimitiveValueOfParameterNamed("balanceSwapOption"); @@ -1314,6 +1324,14 @@ public class SavingsProduct extends AbstractPersistableCustom { this.accountClosedZeroBalanceInDayClosing = accountClosedZeroBalanceInDayClosing; } + public String getProductCategory() { + return productCategory; + } + + public void setProductCategory(String productCategory) { + this.productCategory = productCategory; + } + public boolean isBalanceSwapOption() { return balanceSwapOption; } public void setBalanceSwapOption(boolean v) { this.balanceSwapOption = v; } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductAssembler.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductAssembler.java index df6a4fc9c..c2819cfc1 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductAssembler.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductAssembler.java @@ -282,6 +282,9 @@ public class SavingsProductAssembler { if (command.parameterExists("accountClosedZeroBalanceInDayClosing")) { product.setAccountClosedZeroBalanceInDayClosing(command.booleanObjectValueOfParameterNamed("accountClosedZeroBalanceInDayClosing")); } + if (command.parameterExists("productCategory")) { + product.setProductCategory(command.stringValueOfParameterNamed("productCategory")); + } return product; }