DDS: collection entries held before posting, per-agent GL accounts, maturity auto close
Collections used to reach a customer the moment they were typed, on the reasoning that the money is theirs from the moment the agent takes it. That holds until the agent does not arrive - and then the books say a customer was credited with notes the bank never held, and there was nothing to count the agent's bag against when they did. An agent now records their own round. Those entries sit in m_dds_collection_entry and move nothing: no balance, no ledger, no agent cash. A clerk counts the cash against what they add up to and settles the day, and what that writes is an ordinary collection - posted through the counter's own code rather than a copy of it, so the two cannot drift apart. Each agent may also hold their own ledger account now, one agent to an account. Both sides of the entry, their direction and the amount are unchanged; only which account plays the agent's part differs, and only for agents that name one. What is left standing on an agent's own account is the cash they are holding and have not brought in - a question nobody could answer while every agent shared a single account. A scheme can close its own accounts on maturity, for accounts whose customer named where the proceeds go. The run is generated and reviewed before anything moves, and an account that named no destination is simply not on the list and is paid out by hand as before. Also: the payment mode is a fixed set and stored one way; an agent's birth date must be in the past and of age; a maturity credit account is checked whenever it is written rather than only when the Adv Code happened to arrive beside it; and two report columns stop printing money ungrouped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
37c961f713
commit
12fdf035ca
+21
-5
@@ -75,11 +75,11 @@ public class AgentsApiResource {
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Create a DDS Agent", description = "Mandatory fields: clientId, agentNameId, agentAddress1, agentCity, "
|
||||
+ "agentPinCode, agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate, homeBranch, "
|
||||
+ "savingBranch, tdsApplicable, commissionCalculation\n" + "\n"
|
||||
+ "Optional fields: agentAddress2, agentTelephone, agentOpeningBalance, tdsPercent, transferBranch, "
|
||||
+ "transferAmountAccountType, transferAccountNo, limit, machineRent, agentSecurityBranch, agentSecurityAccountType, "
|
||||
+ "agentSecurityAccountNo, otherCommission, agentDeposit, festivalAdvance, otherDeduction, limits, guarantors")
|
||||
+ "agentPinCode, agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate\n" + "\n"
|
||||
+ "Optional fields: agentAddress2, agentTelephone, agentOpeningBalance, officeId, guarantors\n" + "\n"
|
||||
+ "officeId is the branch the agent belongs to, and defaults to the client's own office. "
|
||||
+ "agentSavingAccountNo must be an open savings account of that same client - the agent's "
|
||||
+ "commission is paid into it.")
|
||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.createAgent() //
|
||||
@@ -101,4 +101,20 @@ public class AgentsApiResource {
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{agentId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Close a DDS Agent", description = "Ends the relationship with an agent. Refused while they still have "
|
||||
+ "accounts assigned, hold collected cash, or are owed commission - the response names which. Distinct from "
|
||||
+ "deactivating the agent, which stops them collecting and is never refused.")
|
||||
public CommandProcessingResult close(@PathParam("agentId") @Parameter(description = "agentId") final Long agentId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.closeAgent(agentId) //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
}
|
||||
|
||||
+23
-1
@@ -26,11 +26,33 @@ import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface AgentRepository extends JpaRepository<Agent, Long> {
|
||||
|
||||
@Query("select coalesce(max(cast(substring(a.agentCode, 3) as long)), 0) from Agent a where a.agentCode like 'AG%'")
|
||||
/**
|
||||
* Native rather than JPQL: {@code cast(... as long)} reaches PostgreSQL verbatim, which knows no
|
||||
* "long" type - the cast has to name a database type.
|
||||
*/
|
||||
@Query(value = "select coalesce(max(cast(substring(agent_code, 3) as bigint)), 0) from m_dds_agent "
|
||||
+ "where agent_code like 'AG%'", nativeQuery = true)
|
||||
Long retrieveMaxAgentCodeSuffix();
|
||||
|
||||
Optional<Agent> findByAgentCode(String agentCode);
|
||||
|
||||
@Query("select a from Agent a where a.id between :agentFrom and :agentTo")
|
||||
List<Agent> findBetweenIds(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo);
|
||||
|
||||
/**
|
||||
* Agents with commission earned but not yet paid - what a Commission Posting run has to pay out.
|
||||
* Agents owed nothing are left out rather than previewed as zero rows.
|
||||
*/
|
||||
@Query("select a from Agent a where a.id between :agentFrom and :agentTo and a.commission > 0")
|
||||
List<Agent> findWithCommissionOwed(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo);
|
||||
|
||||
/**
|
||||
* The agent already holding this ledger account, if any.
|
||||
*
|
||||
* Asked before an account is handed to a second agent. The database refuses it too, but a
|
||||
* constraint violation names a constraint; this names the agent, which is what the person
|
||||
* choosing needs to hear.
|
||||
*/
|
||||
@Query("select a from Agent a where a.glAccount.id = :glAccountId")
|
||||
Agent findByGlAccountId(@Param("glAccountId") Long glAccountId);
|
||||
}
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "AGENT", action = "CLOSE")
|
||||
public class CloseAgentCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final AgentWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public CloseAgentCommandHandler(final AgentWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.closeAgent(command.entityId(), command);
|
||||
}
|
||||
}
|
||||
+190
-127
@@ -22,26 +22,30 @@ import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import java.lang.reflect.Type;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentCannotGuaranteeItselfException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Validates create/update commands for an Agent - the DDS collection agent. As of 2026-08-24, Agent
|
||||
* does the daily collection work directly (the SubAgent tier was removed), so this deserializer carries
|
||||
* the collection/commission/limit/security-account fields SubAgent used to own, alongside Agent's
|
||||
* original identity/settlement-account fields.
|
||||
* Validates create/update commands for an Agent - the DDS collection agent. The parameter set follows
|
||||
* the legacy Agent Master Entry screen. homeBranch is deliberately absent: it is derived from the
|
||||
* agent's client's office in the write service, not supplied by the caller.
|
||||
*/
|
||||
@Component
|
||||
public final class AgentCommandFromApiJsonDeserializer {
|
||||
@@ -58,28 +62,55 @@ public final class AgentCommandFromApiJsonDeserializer {
|
||||
public static final String AGENT_OPENING_DATE = "agentOpeningDate";
|
||||
public static final String AGENT_BIRTH_DATE = "agentBirthDate";
|
||||
public static final String AGENT_OPENING_BALANCE = "agentOpeningBalance";
|
||||
public static final String HOME_BRANCH = "homeBranch";
|
||||
public static final String SAVING_BRANCH = "savingBranch";
|
||||
public static final String TDS_APPLICABLE = "tdsApplicable";
|
||||
public static final String TDS_PERCENT = "tdsPercent";
|
||||
public static final String TRANSFER_BRANCH = "transferBranch";
|
||||
public static final String TRANSFER_AMOUNT_ACCOUNT_TYPE = "transferAmountAccountType";
|
||||
public static final String TRANSFER_ACCOUNT_NO = "transferAccountNo";
|
||||
public static final String COMMISSION_CALCULATION = "commissionCalculation";
|
||||
public static final String LIMIT = "limit";
|
||||
public static final String MACHINE_RENT = "machineRent";
|
||||
public static final String AGENT_SECURITY_BRANCH = "agentSecurityBranch";
|
||||
public static final String AGENT_SECURITY_ACCOUNT_TYPE = "agentSecurityAccountType";
|
||||
public static final String AGENT_SECURITY_ACCOUNT_NO = "agentSecurityAccountNo";
|
||||
public static final String OTHER_COMMISSION = "otherCommission";
|
||||
public static final String AGENT_DEPOSIT = "agentDeposit";
|
||||
public static final String FESTIVAL_ADVANCE = "festivalAdvance";
|
||||
public static final String OTHER_DEDUCTION = "otherDeduction";
|
||||
public static final String LIMITS = "limits";
|
||||
|
||||
/**
|
||||
* An agent handles the branch's cash and signs for it, so they have to be an adult. Kept as a
|
||||
* named constant rather than written into the check, because it is a policy number the bank may
|
||||
* one day want to read off, not an arithmetic detail.
|
||||
*/
|
||||
private static final int MINIMUM_AGENT_AGE_YEARS = 18;
|
||||
|
||||
/**
|
||||
* Which branch the agent works out of, as an office id. Optional: it defaults to the client's
|
||||
* own office, and is only sent when the agent collects out of a different branch from the one
|
||||
* their client record sits in. The DDS batch runs filter by it.
|
||||
*
|
||||
* The branch name is deliberately not accepted - it is derived from the office the id names, so
|
||||
* a caller cannot file an agent under a branch that does not exist, or under a name that
|
||||
* disagrees with the office actually stored.
|
||||
*/
|
||||
public static final String OFFICE_ID = "officeId";
|
||||
|
||||
/**
|
||||
* The ledger account this agent's collections pass through. Required, and one agent to an
|
||||
* account: what is left standing on it is the cash this agent is holding and has not brought in,
|
||||
* which is a question nobody could answer while every agent shared one account.
|
||||
*
|
||||
* The agents created before this existed carry none, and go on posting to the shared account
|
||||
* until somebody opens them and chooses - the rule is enforced on what is written from here on,
|
||||
* not applied backwards over records nobody has looked at.
|
||||
*/
|
||||
public static final String GL_ACCOUNT_ID = "glAccountId";
|
||||
|
||||
public static final String ACTIVE = "active";
|
||||
public static final String GUARANTORS = "guarantors";
|
||||
public static final String DATE_FORMAT = "dateFormat";
|
||||
public static final String LOCALE = "locale";
|
||||
|
||||
/**
|
||||
* Commission settings and the standing deductions the commission run applies. Update-only: an
|
||||
* agent is created earning full commission with nothing deducted, and these are set afterwards
|
||||
* when the bank decides otherwise. The running totals the batch maintains - the commission owed
|
||||
* and the TDS withheld - are not parameters at all; they are outcomes of posted runs.
|
||||
*/
|
||||
public static final String COMMISSION_CALCULATION = "commissionCalculation";
|
||||
public static final String TDS_APPLICABLE = "tdsApplicable";
|
||||
public static final String TDS_PERCENT = "tdsPercent";
|
||||
public static final String DEDUCT_COMMISSION = "deductCommission";
|
||||
public static final String AGENT_DEPOSIT = "agentDeposit";
|
||||
public static final String FESTIVAL_ADVANCE = "festivalAdvance";
|
||||
public static final String OTHER_DEDUCTION = "otherDeduction";
|
||||
|
||||
private static final String GUARANTOR_CLIENT_ID = "clientId";
|
||||
private static final String GUARANTOR_NAME = "guarantorName";
|
||||
private static final String GUARANTOR_ADDRESS1 = "address1";
|
||||
@@ -90,16 +121,29 @@ public final class AgentCommandFromApiJsonDeserializer {
|
||||
|
||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList(CLIENT_ID, AGENT_NAME_ID, AGENT_ADDRESS1,
|
||||
AGENT_ADDRESS2, AGENT_CITY, AGENT_PINCODE, AGENT_TELEPHONE, AGENT_SAVING_ACCOUNT_TYPE, AGENT_SAVING_ACCOUNT_NO,
|
||||
AGENT_OPENING_DATE, AGENT_BIRTH_DATE, AGENT_OPENING_BALANCE, HOME_BRANCH, SAVING_BRANCH, TDS_APPLICABLE, TDS_PERCENT,
|
||||
TRANSFER_BRANCH, TRANSFER_AMOUNT_ACCOUNT_TYPE, TRANSFER_ACCOUNT_NO, COMMISSION_CALCULATION, LIMIT, MACHINE_RENT,
|
||||
AGENT_SECURITY_BRANCH, AGENT_SECURITY_ACCOUNT_TYPE, AGENT_SECURITY_ACCOUNT_NO, OTHER_COMMISSION, AGENT_DEPOSIT,
|
||||
FESTIVAL_ADVANCE, OTHER_DEDUCTION, LIMITS, GUARANTORS, DATE_FORMAT, LOCALE));
|
||||
AGENT_OPENING_DATE, AGENT_BIRTH_DATE, AGENT_OPENING_BALANCE, OFFICE_ID, GL_ACCOUNT_ID, ACTIVE, GUARANTORS, DATE_FORMAT,
|
||||
LOCALE));
|
||||
|
||||
private static final Set<String> SUPPORTED_LIMIT_PARAMETERS = new HashSet<>(Arrays.asList("fromDate", "toDate", "limit"));
|
||||
private static final Set<String> SUPPORTED_UPDATE_PARAMETERS = new HashSet<>(SUPPORTED_PARAMETERS);
|
||||
static {
|
||||
SUPPORTED_UPDATE_PARAMETERS.addAll(Arrays.asList(COMMISSION_CALCULATION, TDS_APPLICABLE, TDS_PERCENT, DEDUCT_COMMISSION,
|
||||
AGENT_DEPOSIT, FESTIVAL_ADVANCE, OTHER_DEDUCTION));
|
||||
}
|
||||
|
||||
private static final Set<String> SUPPORTED_GUARANTOR_PARAMETERS = new HashSet<>(Arrays.asList(GUARANTOR_CLIENT_ID, GUARANTOR_NAME,
|
||||
GUARANTOR_ADDRESS1, GUARANTOR_ADDRESS2, GUARANTOR_CITY, GUARANTOR_PINCODE, GUARANTOR_TELEPHONE));
|
||||
|
||||
/*
|
||||
* What a phone number and a pincode have to look like.
|
||||
*
|
||||
* Length alone was all these carried, so either could be typed as letters and nothing would say
|
||||
* so until somebody tried to ring the agent or post to them. Kept in step with the Party screen,
|
||||
* which asks for the same two things of the customer.
|
||||
*/
|
||||
private static final Pattern TELEPHONE_PATTERN = Pattern.compile("^[0-9+()\\-\\s]{6,20}$");
|
||||
|
||||
private static final Pattern PINCODE_PATTERN = Pattern.compile("^[1-9][0-9]{5}$");
|
||||
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
|
||||
@Autowired
|
||||
@@ -141,10 +185,14 @@ public final class AgentCommandFromApiJsonDeserializer {
|
||||
|
||||
final String agentPinCode = this.fromApiJsonHelper.extractStringNamed(AGENT_PINCODE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_PINCODE).value(agentPinCode).notBlank().notExceedingLengthOf(20);
|
||||
mustLookLike(baseDataValidator, AGENT_PINCODE, agentPinCode, PINCODE_PATTERN,
|
||||
"Pincode must be 6 digits and cannot start with 0");
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_TELEPHONE, element)) {
|
||||
final String agentTelephone = this.fromApiJsonHelper.extractStringNamed(AGENT_TELEPHONE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_TELEPHONE).value(agentTelephone).ignoreIfNull().notExceedingLengthOf(20);
|
||||
mustLookLike(baseDataValidator, AGENT_TELEPHONE, agentTelephone, TELEPHONE_PATTERN,
|
||||
"Telephone number may contain only digits, spaces and + - ( )");
|
||||
}
|
||||
|
||||
final String agentSavingAccountType = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_TYPE, element);
|
||||
@@ -154,33 +202,34 @@ public final class AgentCommandFromApiJsonDeserializer {
|
||||
final String agentSavingAccountNo = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_NO, element);
|
||||
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_NO).value(agentSavingAccountNo).notBlank().notExceedingLengthOf(50);
|
||||
|
||||
// Optional: the write service falls back to the client's own office. Only checked for shape
|
||||
// here - that the office exists is the write service's business.
|
||||
if (this.fromApiJsonHelper.parameterExists(OFFICE_ID, element)) {
|
||||
final Long officeId = this.fromApiJsonHelper.extractLongNamed(OFFICE_ID, element);
|
||||
baseDataValidator.reset().parameter(OFFICE_ID).value(officeId).ignoreIfNull().integerGreaterThanZero();
|
||||
}
|
||||
|
||||
// Required, because an agent without one of their own falls back to the account every other
|
||||
// agent shares - and a balance sitting on that account says money is outstanding without
|
||||
// saying whose, which is the whole thing this field exists to stop.
|
||||
//
|
||||
// Shape only. Whether the account exists, may carry entries, and is not already another
|
||||
// agent's is the write service's business - it has the ledger and the other agents to hand.
|
||||
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(GL_ACCOUNT_ID, element);
|
||||
baseDataValidator.reset().parameter(GL_ACCOUNT_ID).value(glAccountId).notNull().integerGreaterThanZero();
|
||||
|
||||
final var agentOpeningDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_OPENING_DATE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_OPENING_DATE).value(agentOpeningDate).notNull();
|
||||
|
||||
final var agentBirthDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_BIRTH_DATE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate).notNull();
|
||||
validateAgentBirthDate(agentBirthDate, baseDataValidator);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_BALANCE, element)) {
|
||||
final var agentOpeningBalance = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AGENT_OPENING_BALANCE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_OPENING_BALANCE).value(agentOpeningBalance).ignoreIfNull().zeroOrPositiveAmount();
|
||||
}
|
||||
|
||||
final String homeBranch = this.fromApiJsonHelper.extractStringNamed(HOME_BRANCH, element);
|
||||
baseDataValidator.reset().parameter(HOME_BRANCH).value(homeBranch).notBlank().notExceedingLengthOf(100);
|
||||
|
||||
final String savingBranch = this.fromApiJsonHelper.extractStringNamed(SAVING_BRANCH, element);
|
||||
baseDataValidator.reset().parameter(SAVING_BRANCH).value(savingBranch).notBlank().notExceedingLengthOf(100);
|
||||
|
||||
baseDataValidator.reset().parameter(TDS_APPLICABLE).trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(TDS_APPLICABLE,
|
||||
element));
|
||||
baseDataValidator.reset().parameter(COMMISSION_CALCULATION)
|
||||
.trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(COMMISSION_CALCULATION, element));
|
||||
|
||||
validateOptionalStrings(element, baseDataValidator);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(LIMITS, element)) {
|
||||
validateLimits(element.getAsJsonObject(), dataValidationErrors);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(GUARANTORS, element)) {
|
||||
validateGuarantors(element.getAsJsonObject(), dataValidationErrors);
|
||||
}
|
||||
@@ -196,7 +245,7 @@ public final class AgentCommandFromApiJsonDeserializer {
|
||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
|
||||
|
||||
}.getType();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_UPDATE_PARAMETERS);
|
||||
|
||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent");
|
||||
@@ -231,11 +280,15 @@ public final class AgentCommandFromApiJsonDeserializer {
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_PINCODE, element)) {
|
||||
final String agentPinCode = this.fromApiJsonHelper.extractStringNamed(AGENT_PINCODE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_PINCODE).value(agentPinCode).notBlank().notExceedingLengthOf(20);
|
||||
mustLookLike(baseDataValidator, AGENT_PINCODE, agentPinCode, PINCODE_PATTERN,
|
||||
"Pincode must be 6 digits and cannot start with 0");
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_TELEPHONE, element)) {
|
||||
final String agentTelephone = this.fromApiJsonHelper.extractStringNamed(AGENT_TELEPHONE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_TELEPHONE).value(agentTelephone).ignoreIfNull().notExceedingLengthOf(20);
|
||||
mustLookLike(baseDataValidator, AGENT_TELEPHONE, agentTelephone, TELEPHONE_PATTERN,
|
||||
"Telephone number may contain only digits, spaces and + - ( )");
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_SAVING_ACCOUNT_TYPE, element)) {
|
||||
@@ -257,6 +310,7 @@ public final class AgentCommandFromApiJsonDeserializer {
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_BIRTH_DATE, element)) {
|
||||
final var agentBirthDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_BIRTH_DATE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate).notNull();
|
||||
validateAgentBirthDate(agentBirthDate, baseDataValidator);
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_BALANCE, element)) {
|
||||
@@ -264,31 +318,43 @@ public final class AgentCommandFromApiJsonDeserializer {
|
||||
baseDataValidator.reset().parameter(AGENT_OPENING_BALANCE).value(agentOpeningBalance).ignoreIfNull().zeroOrPositiveAmount();
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(HOME_BRANCH, element)) {
|
||||
final String homeBranch = this.fromApiJsonHelper.extractStringNamed(HOME_BRANCH, element);
|
||||
baseDataValidator.reset().parameter(HOME_BRANCH).value(homeBranch).notBlank().notExceedingLengthOf(100);
|
||||
// Sent means chosen: an update may leave the field out and keep what the agent has, but it
|
||||
// may not send it empty. Clearing it would put the agent back on the shared account, which
|
||||
// is the state this field was added to get out of.
|
||||
if (this.fromApiJsonHelper.parameterExists(GL_ACCOUNT_ID, element)) {
|
||||
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(GL_ACCOUNT_ID, element);
|
||||
baseDataValidator.reset().parameter(GL_ACCOUNT_ID).value(glAccountId).notNull().integerGreaterThanZero();
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(SAVING_BRANCH, element)) {
|
||||
final String savingBranch = this.fromApiJsonHelper.extractStringNamed(SAVING_BRANCH, element);
|
||||
baseDataValidator.reset().parameter(SAVING_BRANCH).value(savingBranch).notBlank().notExceedingLengthOf(100);
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(TDS_APPLICABLE, element)) {
|
||||
baseDataValidator.reset().parameter(TDS_APPLICABLE)
|
||||
.trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(TDS_APPLICABLE, element));
|
||||
// Only ever sent on its own, by the list's activate/deactivate action - an agent is always
|
||||
// created active, so this is an update-time parameter.
|
||||
if (this.fromApiJsonHelper.parameterExists(ACTIVE, element)) {
|
||||
final Boolean active = this.fromApiJsonHelper.extractBooleanNamed(ACTIVE, element);
|
||||
baseDataValidator.reset().parameter(ACTIVE).value(active).notNull();
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(COMMISSION_CALCULATION, element)) {
|
||||
baseDataValidator.reset().parameter(COMMISSION_CALCULATION)
|
||||
.trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(COMMISSION_CALCULATION, element));
|
||||
final Boolean commissionCalculation = this.fromApiJsonHelper.extractBooleanNamed(COMMISSION_CALCULATION, element);
|
||||
baseDataValidator.reset().parameter(COMMISSION_CALCULATION).value(commissionCalculation).notNull();
|
||||
}
|
||||
|
||||
validateOptionalStrings(element, baseDataValidator);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(LIMITS, element)) {
|
||||
validateLimits(element.getAsJsonObject(), dataValidationErrors);
|
||||
if (this.fromApiJsonHelper.parameterExists(TDS_APPLICABLE, element)) {
|
||||
final Boolean tdsApplicable = this.fromApiJsonHelper.extractBooleanNamed(TDS_APPLICABLE, element);
|
||||
baseDataValidator.reset().parameter(TDS_APPLICABLE).value(tdsApplicable).notNull();
|
||||
}
|
||||
|
||||
// A tax rate above 100% would take more than the commission it is levied on.
|
||||
if (this.fromApiJsonHelper.parameterExists(TDS_PERCENT, element)) {
|
||||
final var tdsPercent = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(TDS_PERCENT, element);
|
||||
baseDataValidator.reset().parameter(TDS_PERCENT).value(tdsPercent).ignoreIfNull().zeroOrPositiveAmount()
|
||||
.notGreaterThanMax(BigDecimal.valueOf(100));
|
||||
}
|
||||
|
||||
validateDeduction(baseDataValidator, element, DEDUCT_COMMISSION);
|
||||
validateDeduction(baseDataValidator, element, AGENT_DEPOSIT);
|
||||
validateDeduction(baseDataValidator, element, FESTIVAL_ADVANCE);
|
||||
validateDeduction(baseDataValidator, element, OTHER_DEDUCTION);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(GUARANTORS, element)) {
|
||||
validateGuarantors(element.getAsJsonObject(), dataValidationErrors);
|
||||
}
|
||||
@@ -296,87 +362,67 @@ public final class AgentCommandFromApiJsonDeserializer {
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
}
|
||||
|
||||
private void validateOptionalStrings(final JsonElement element, final DataValidatorBuilder v) {
|
||||
if (this.fromApiJsonHelper.parameterExists(TDS_PERCENT, element)) {
|
||||
v.reset().parameter(TDS_PERCENT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(TDS_PERCENT, element))
|
||||
.ignoreIfNull().zeroOrPositiveAmount();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(TRANSFER_BRANCH, element)) {
|
||||
v.reset().parameter(TRANSFER_BRANCH).value(this.fromApiJsonHelper.extractStringNamed(TRANSFER_BRANCH, element))
|
||||
.ignoreIfNull().notExceedingLengthOf(100);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(TRANSFER_AMOUNT_ACCOUNT_TYPE, element)) {
|
||||
v.reset().parameter(TRANSFER_AMOUNT_ACCOUNT_TYPE)
|
||||
.value(this.fromApiJsonHelper.extractStringNamed(TRANSFER_AMOUNT_ACCOUNT_TYPE, element)).ignoreIfNull()
|
||||
.notExceedingLengthOf(100);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(TRANSFER_ACCOUNT_NO, element)) {
|
||||
v.reset().parameter(TRANSFER_ACCOUNT_NO).value(this.fromApiJsonHelper.extractStringNamed(TRANSFER_ACCOUNT_NO, element))
|
||||
.ignoreIfNull().notExceedingLengthOf(50);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(LIMIT, element)) {
|
||||
v.reset().parameter(LIMIT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(LIMIT, element)).ignoreIfNull()
|
||||
.zeroOrPositiveAmount();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(MACHINE_RENT, element)) {
|
||||
v.reset().parameter(MACHINE_RENT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(MACHINE_RENT, element))
|
||||
.ignoreIfNull().zeroOrPositiveAmount();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_SECURITY_BRANCH, element)) {
|
||||
v.reset().parameter(AGENT_SECURITY_BRANCH).value(this.fromApiJsonHelper.extractStringNamed(AGENT_SECURITY_BRANCH, element))
|
||||
.ignoreIfNull().notExceedingLengthOf(100);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_SECURITY_ACCOUNT_TYPE, element)) {
|
||||
v.reset().parameter(AGENT_SECURITY_ACCOUNT_TYPE)
|
||||
.value(this.fromApiJsonHelper.extractStringNamed(AGENT_SECURITY_ACCOUNT_TYPE, element)).ignoreIfNull()
|
||||
.notExceedingLengthOf(100);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_SECURITY_ACCOUNT_NO, element)) {
|
||||
v.reset().parameter(AGENT_SECURITY_ACCOUNT_NO)
|
||||
.value(this.fromApiJsonHelper.extractStringNamed(AGENT_SECURITY_ACCOUNT_NO, element)).ignoreIfNull()
|
||||
.notExceedingLengthOf(50);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(OTHER_COMMISSION, element)) {
|
||||
v.reset().parameter(OTHER_COMMISSION).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(OTHER_COMMISSION, element))
|
||||
.ignoreIfNull().zeroOrPositiveAmount();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_DEPOSIT, element)) {
|
||||
v.reset().parameter(AGENT_DEPOSIT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AGENT_DEPOSIT, element))
|
||||
.ignoreIfNull().zeroOrPositiveAmount();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(FESTIVAL_ADVANCE, element)) {
|
||||
v.reset().parameter(FESTIVAL_ADVANCE).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(FESTIVAL_ADVANCE, element))
|
||||
.ignoreIfNull().zeroOrPositiveAmount();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(OTHER_DEDUCTION, element)) {
|
||||
v.reset().parameter(OTHER_DEDUCTION).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(OTHER_DEDUCTION, element))
|
||||
.ignoreIfNull().zeroOrPositiveAmount();
|
||||
/**
|
||||
* A deduction is an amount withheld, so it is entered as a positive figure - a negative one would
|
||||
* quietly pay the agent more rather than less.
|
||||
*/
|
||||
private void validateDeduction(final DataValidatorBuilder baseDataValidator, final JsonElement element, final String parameterName) {
|
||||
if (this.fromApiJsonHelper.parameterExists(parameterName, element)) {
|
||||
final var value = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(parameterName, element);
|
||||
baseDataValidator.reset().parameter(parameterName).value(value).ignoreIfNull().zeroOrPositiveAmount();
|
||||
}
|
||||
}
|
||||
|
||||
private void validateLimits(final JsonObject topLevelJson, final List<ApiParameterError> dataValidationErrors) {
|
||||
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("agent.limit");
|
||||
final var limitsArray = topLevelJson.getAsJsonArray(LIMITS);
|
||||
for (int i = 0; i < limitsArray.size(); i++) {
|
||||
final JsonObject limitJson = limitsArray.get(i).getAsJsonObject();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(limitJson, SUPPORTED_LIMIT_PARAMETERS);
|
||||
/**
|
||||
* A date of birth that is today or later describes nobody, and until now nothing stopped one
|
||||
* being saved - the field was only checked for being present. The slip is easy to make, because
|
||||
* the date picker opens on today and a stray Enter takes it.
|
||||
*
|
||||
* The age is measured against today rather than against the opening date: an agent opened years
|
||||
* ago has only grown older since, so today is the reading that cannot wrongly reject a record
|
||||
* that was legitimate when it was made.
|
||||
*
|
||||
* Only the shape of the date is judged here. Whether the person should be an agent at all is the
|
||||
* branch's decision, not the validator's.
|
||||
*/
|
||||
private void validateAgentBirthDate(final LocalDate agentBirthDate, final DataValidatorBuilder baseDataValidator) {
|
||||
if (agentBirthDate == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
v.reset().parameterAtIndexArray("fromDate", i).value(extractString(limitJson, "fromDate")).notBlank();
|
||||
v.reset().parameterAtIndexArray("toDate", i).value(extractString(limitJson, "toDate")).notBlank();
|
||||
v.reset().parameterAtIndexArray("limit", i).value(extractBigDecimal(limitJson, "limit")).notNull().zeroOrPositiveAmount();
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
|
||||
if (!agentBirthDate.isBefore(today)) {
|
||||
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate)
|
||||
.failWithCode("cannot.be.today.or.a.future.date");
|
||||
return;
|
||||
}
|
||||
|
||||
if (agentBirthDate.isAfter(today.minusYears(MINIMUM_AGENT_AGE_YEARS))) {
|
||||
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate)
|
||||
.failWithCode("agent.must.be.at.least." + MINIMUM_AGENT_AGE_YEARS + ".years.old");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateGuarantors(final JsonObject topLevelJson, final List<ApiParameterError> dataValidationErrors) {
|
||||
final DataValidatorBuilder guarantorValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent.guarantor");
|
||||
|
||||
final Long agentClientId = extractLong(topLevelJson, CLIENT_ID);
|
||||
|
||||
final var guarantorsArray = topLevelJson.getAsJsonArray(GUARANTORS);
|
||||
for (int i = 0; i < guarantorsArray.size(); i++) {
|
||||
final JsonObject guarantorJson = guarantorsArray.get(i).getAsJsonObject();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(guarantorJson, SUPPORTED_GUARANTOR_PARAMETERS);
|
||||
|
||||
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_CLIENT_ID, i)
|
||||
.value(extractLong(guarantorJson, GUARANTOR_CLIENT_ID)).notNull().integerGreaterThanZero();
|
||||
final Long guarantorClientId = extractLong(guarantorJson, GUARANTOR_CLIENT_ID);
|
||||
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_CLIENT_ID, i).value(guarantorClientId).notNull()
|
||||
.integerGreaterThanZero();
|
||||
|
||||
// An agent cannot stand guarantor for itself - a guarantee only counts coming from
|
||||
// someone else.
|
||||
if (agentClientId != null && agentClientId.equals(guarantorClientId)) {
|
||||
throw new AgentCannotGuaranteeItselfException(agentClientId);
|
||||
}
|
||||
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_NAME, i).value(extractString(guarantorJson, GUARANTOR_NAME))
|
||||
.notBlank().notExceedingLengthOf(100);
|
||||
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_ADDRESS1, i).value(extractString(guarantorJson, GUARANTOR_ADDRESS1))
|
||||
@@ -406,4 +452,21 @@ public final class AgentCommandFromApiJsonDeserializer {
|
||||
dataValidationErrors);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a value against the shape its kind has to take, and says what that shape is.
|
||||
*
|
||||
* The message names the format rather than only rejecting the entry: "must be 6 digits" tells
|
||||
* the clerk what to type next, where "invalid" leaves them guessing.
|
||||
*/
|
||||
private void mustLookLike(final DataValidatorBuilder v, final String paramName, final String value, final Pattern shape,
|
||||
final String describedAs) {
|
||||
if (org.apache.commons.lang3.StringUtils.isBlank(value)) {
|
||||
return;
|
||||
}
|
||||
// The message is given to the validator rather than left in the args, so what the clerk
|
||||
// reads is what to type next rather than that something was wrong.
|
||||
v.reset().parameter(paramName).value(value).matchesRegularExpression(shape.pattern(), describedAs);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+62
-63
@@ -20,6 +20,8 @@ package org.apache.fineract.organisation.agent.service;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -29,7 +31,6 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
|
||||
import org.apache.fineract.organisation.agent.data.AgentData;
|
||||
import org.apache.fineract.organisation.agent.data.AgentGuarantorData;
|
||||
import org.apache.fineract.organisation.agent.data.AgentLimitData;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -41,7 +42,6 @@ public class AgentReadPlatformServiceImpl implements AgentReadPlatformService {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
private static final AgentMapper AGENT_MAPPER = new AgentMapper();
|
||||
private static final AgentLimitMapper LIMIT_MAPPER = new AgentLimitMapper();
|
||||
private static final AgentGuarantorMapper GUARANTOR_MAPPER = new AgentGuarantorMapper();
|
||||
|
||||
private static final class AgentMapper implements RowMapper<AgentData> {
|
||||
@@ -53,50 +53,68 @@ public class AgentReadPlatformServiceImpl implements AgentReadPlatformService {
|
||||
+ " a.agent_saving_account_type as agentSavingAccountType, a.agent_saving_account_no as agentSavingAccountNo, "
|
||||
+ " a.agent_opening_date as agentOpeningDate, a.agent_birth_date as agentBirthDate, "
|
||||
+ " a.agent_current_balance as agentCurrentBalance, a.agent_opening_balance as agentOpeningBalance, "
|
||||
+ " a.home_branch as homeBranch, a.saving_branch as savingBranch, a.tds_applicable as tdsApplicable, "
|
||||
+ " a.tds_percent as tdsPercent, a.current_tds as currentTds, a.tds_amount as tdsAmount, "
|
||||
+ " a.transfer_branch as transferBranch, a.transfer_amount_account_type as transferAmountAccountType, "
|
||||
+ " a.transfer_account_no as transferAccountNo, a.collector_commission_calculation as commissionCalculation, "
|
||||
+ " a.collector_limit_amount as limitAmount, a.machine_rent as machineRent, "
|
||||
+ " a.agent_security_branch as agentSecurityBranch, a.agent_security_account_type as agentSecurityAccountType, "
|
||||
+ " a.agent_security_account_no as agentSecurityAccountNo, a.commission as commission, "
|
||||
+ " a.other_commission as otherCommission, a.agent_deposit as agentDeposit, "
|
||||
+ " a.festival_advance as festivalAdvance, a.other_deduction as otherDeduction "
|
||||
+ " from m_dds_agent a join m_client c on c.id = a.client_id";
|
||||
// The branch name is read off the office itself, so renaming a branch shows
|
||||
// through here rather than leaving every agent under the old name.
|
||||
+ " a.office_id as officeId, coalesce(o.name, a.home_branch) as homeBranch, "
|
||||
// The ledger account this agent's collections pass through, with its code and name so
|
||||
// the screen can show what was chosen without a second call.
|
||||
+ " a.gl_account_id as glAccountId, gl.gl_code as glAccountCode, gl.name as glAccountName, "
|
||||
+ " a.commission as commission, a.tds_amount as tdsAmount, "
|
||||
+ " a.commission_calculation as commissionCalculation, a.tds_applicable as tdsApplicable, "
|
||||
+ " a.tds_percent as tdsPercent, a.deduct_commission as deductCommission, a.agent_deposit as agentDeposit, "
|
||||
+ " a.festival_advance as festivalAdvance, a.other_deduction as otherDeduction, "
|
||||
+ " a.is_active as active, a.close_date as closeDate, "
|
||||
+ " a.created_date as createdDate, a.lastmodified_date as lastModifiedDate "
|
||||
+ " from m_dds_agent a join m_client c on c.id = a.client_id"
|
||||
+ " left join m_office o on o.id = a.office_id"
|
||||
+ " left join acc_gl_account gl on gl.id = a.gl_account_id";
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgentData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||
return AgentData.instance(rs.getLong("id"), rs.getLong("clientId"), rs.getString("clientName"), rs.getString("agentCode"),
|
||||
rs.getString("agentNameId"), rs.getString("agentAddress1"), rs.getString("agentAddress2"),
|
||||
rs.getString("agentCity"), rs.getString("agentPinCode"), rs.getString("agentTelephone"),
|
||||
rs.getString("agentSavingAccountType"), rs.getString("agentSavingAccountNo"),
|
||||
JdbcSupport.getLocalDate(rs, "agentOpeningDate"), JdbcSupport.getLocalDate(rs, "agentBirthDate"),
|
||||
rs.getBigDecimal("agentCurrentBalance"), rs.getBigDecimal("agentOpeningBalance"), rs.getString("homeBranch"),
|
||||
rs.getString("savingBranch"), rs.getBoolean("tdsApplicable"), rs.getBigDecimal("tdsPercent"),
|
||||
rs.getBigDecimal("currentTds"), rs.getBigDecimal("tdsAmount"), rs.getString("transferBranch"),
|
||||
rs.getString("transferAmountAccountType"), rs.getString("transferAccountNo"),
|
||||
rs.getBoolean("commissionCalculation"), rs.getBigDecimal("limitAmount"), rs.getBigDecimal("machineRent"),
|
||||
rs.getString("agentSecurityBranch"), rs.getString("agentSecurityAccountType"),
|
||||
rs.getString("agentSecurityAccountNo"), rs.getBigDecimal("commission"), rs.getBigDecimal("otherCommission"),
|
||||
rs.getBigDecimal("agentDeposit"), rs.getBigDecimal("festivalAdvance"), rs.getBigDecimal("otherDeduction"),
|
||||
new ArrayList<>(), new ArrayList<>());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AgentLimitMapper implements RowMapper<Map.Entry<Long, AgentLimitData>> {
|
||||
|
||||
public String schema() {
|
||||
return " l.agent_id as agentId, l.id as id, l.from_date as fromDate, l.to_date as toDate, "
|
||||
+ " l.limit_amount as limitAmount from m_dds_agent_limit l";
|
||||
return AgentData.builder() //
|
||||
.id(rs.getLong("id")) //
|
||||
.clientId(rs.getLong("clientId")) //
|
||||
.clientName(rs.getString("clientName")) //
|
||||
.agentCode(rs.getString("agentCode")) //
|
||||
.agentNameId(rs.getString("agentNameId")) //
|
||||
.agentAddress1(rs.getString("agentAddress1")) //
|
||||
.agentAddress2(rs.getString("agentAddress2")) //
|
||||
.agentCity(rs.getString("agentCity")) //
|
||||
.agentPinCode(rs.getString("agentPinCode")) //
|
||||
.agentTelephone(rs.getString("agentTelephone")) //
|
||||
.agentSavingAccountType(rs.getString("agentSavingAccountType")) //
|
||||
.agentSavingAccountNo(rs.getString("agentSavingAccountNo")) //
|
||||
.agentOpeningDate(JdbcSupport.getLocalDate(rs, "agentOpeningDate")) //
|
||||
.agentBirthDate(JdbcSupport.getLocalDate(rs, "agentBirthDate")) //
|
||||
.agentCurrentBalance(rs.getBigDecimal("agentCurrentBalance")) //
|
||||
.agentOpeningBalance(rs.getBigDecimal("agentOpeningBalance")) //
|
||||
.officeId(JdbcSupport.getLong(rs, "officeId")) //
|
||||
.glAccountId(JdbcSupport.getLong(rs, "glAccountId")) //
|
||||
.glAccountCode(rs.getString("glAccountCode")) //
|
||||
.glAccountName(rs.getString("glAccountName")) //
|
||||
.homeBranch(rs.getString("homeBranch")) //
|
||||
.commission(rs.getBigDecimal("commission")) //
|
||||
.tdsAmount(rs.getBigDecimal("tdsAmount")) //
|
||||
.commissionCalculation(rs.getBoolean("commissionCalculation")) //
|
||||
.tdsApplicable(rs.getBoolean("tdsApplicable")) //
|
||||
.tdsPercent(rs.getBigDecimal("tdsPercent")) //
|
||||
.deductCommission(rs.getBigDecimal("deductCommission")) //
|
||||
.agentDeposit(rs.getBigDecimal("agentDeposit")) //
|
||||
.festivalAdvance(rs.getBigDecimal("festivalAdvance")) //
|
||||
.otherDeduction(rs.getBigDecimal("otherDeduction")) //
|
||||
.active(rs.getBoolean("active")) //
|
||||
.closeDate(JdbcSupport.getLocalDate(rs, "closeDate")) //
|
||||
.createdDate(localDateTime(rs, "createdDate")) //
|
||||
.lastModifiedDate(localDateTime(rs, "lastModifiedDate")) //
|
||||
.guarantors(new ArrayList<>()) //
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map.Entry<Long, AgentLimitData> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||
final Long agentId = rs.getLong("agentId");
|
||||
final AgentLimitData data = AgentLimitData.instance(rs.getLong("id"), JdbcSupport.getLocalDate(rs, "fromDate"),
|
||||
JdbcSupport.getLocalDate(rs, "toDate"), rs.getBigDecimal("limitAmount"));
|
||||
return Map.entry(agentId, data);
|
||||
/** JdbcSupport has no LocalDateTime helper, and these audit columns are nullable. */
|
||||
private LocalDateTime localDateTime(final ResultSet rs, final String columnName) throws SQLException {
|
||||
final Timestamp timestamp = rs.getTimestamp(columnName);
|
||||
return timestamp == null ? null : timestamp.toLocalDateTime();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +140,7 @@ public class AgentReadPlatformServiceImpl implements AgentReadPlatformService {
|
||||
public Collection<AgentData> retrieveAll() {
|
||||
final String sql = "select " + AGENT_MAPPER.schema() + " order by a.id";
|
||||
final List<AgentData> agents = this.jdbcTemplate.query(sql, AGENT_MAPPER); // NOSONAR
|
||||
return attachChildren(agents);
|
||||
return attachGuarantors(agents);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -130,24 +148,17 @@ public class AgentReadPlatformServiceImpl implements AgentReadPlatformService {
|
||||
try {
|
||||
final String sql = "select " + AGENT_MAPPER.schema() + " where a.id = ?";
|
||||
final AgentData agent = this.jdbcTemplate.queryForObject(sql, AGENT_MAPPER, agentId); // NOSONAR
|
||||
return attachChildren(List.of(agent)).iterator().next();
|
||||
return attachGuarantors(List.of(agent)).iterator().next();
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
throw new AgentNotFoundException(agentId, e);
|
||||
}
|
||||
}
|
||||
|
||||
private Collection<AgentData> attachChildren(final List<AgentData> agents) {
|
||||
private Collection<AgentData> attachGuarantors(final List<AgentData> agents) {
|
||||
if (agents.isEmpty()) {
|
||||
return agents;
|
||||
}
|
||||
|
||||
final String limitSql = "select " + LIMIT_MAPPER.schema() + " order by l.id";
|
||||
final List<Map.Entry<Long, AgentLimitData>> limitRows = this.jdbcTemplate.query(limitSql, LIMIT_MAPPER); // NOSONAR
|
||||
final Map<Long, List<AgentLimitData>> limitsByAgentId = new LinkedHashMap<>();
|
||||
for (final Map.Entry<Long, AgentLimitData> row : limitRows) {
|
||||
limitsByAgentId.computeIfAbsent(row.getKey(), k -> new ArrayList<>()).add(row.getValue());
|
||||
}
|
||||
|
||||
final String guarantorSql = "select " + GUARANTOR_MAPPER.schema() + " order by g.id";
|
||||
final List<Map.Entry<Long, AgentGuarantorData>> guarantorRows = this.jdbcTemplate.query(guarantorSql, GUARANTOR_MAPPER); // NOSONAR
|
||||
final Map<Long, List<AgentGuarantorData>> guarantorsByAgentId = new LinkedHashMap<>();
|
||||
@@ -157,19 +168,7 @@ public class AgentReadPlatformServiceImpl implements AgentReadPlatformService {
|
||||
|
||||
final List<AgentData> result = new ArrayList<>(agents.size());
|
||||
for (final AgentData agent : agents) {
|
||||
final List<AgentLimitData> limits = limitsByAgentId.getOrDefault(agent.getId(), new ArrayList<>());
|
||||
final List<AgentGuarantorData> guarantors = guarantorsByAgentId.getOrDefault(agent.getId(), new ArrayList<>());
|
||||
result.add(AgentData.instance(agent.getId(), agent.getClientId(), agent.getClientName(), agent.getAgentCode(),
|
||||
agent.getAgentNameId(), agent.getAgentAddress1(), agent.getAgentAddress2(), agent.getAgentCity(),
|
||||
agent.getAgentPinCode(), agent.getAgentTelephone(), agent.getAgentSavingAccountType(),
|
||||
agent.getAgentSavingAccountNo(), agent.getAgentOpeningDate(), agent.getAgentBirthDate(),
|
||||
agent.getAgentCurrentBalance(), agent.getAgentOpeningBalance(), agent.getHomeBranch(), agent.getSavingBranch(),
|
||||
agent.getTdsApplicable(), agent.getTdsPercent(), agent.getCurrentTds(), agent.getTdsAmount(),
|
||||
agent.getTransferBranch(), agent.getTransferAmountAccountType(), agent.getTransferAccountNo(),
|
||||
agent.getCommissionCalculation(), agent.getLimit(), agent.getMachineRent(), agent.getAgentSecurityBranch(),
|
||||
agent.getAgentSecurityAccountType(), agent.getAgentSecurityAccountNo(), agent.getCommission(),
|
||||
agent.getOtherCommission(), agent.getAgentDeposit(), agent.getFestivalAdvance(), agent.getOtherDeduction(),
|
||||
limits, guarantors));
|
||||
result.add(agent.toBuilder().guarantors(guarantorsByAgentId.getOrDefault(agent.getId(), new ArrayList<>())).build());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
+7
@@ -26,4 +26,11 @@ public interface AgentWritePlatformService {
|
||||
CommandProcessingResult createAgent(JsonCommand command);
|
||||
|
||||
CommandProcessingResult updateAgent(Long agentId, JsonCommand command);
|
||||
|
||||
/**
|
||||
* Ends the relationship with an agent. Refused while they still have accounts assigned, hold
|
||||
* collected cash, or are owed commission - each of those would be left with nobody responsible
|
||||
* for it. Distinct from deactivating, which stops an agent collecting and is never blocked.
|
||||
*/
|
||||
CommandProcessingResult closeAgent(Long agentId, JsonCommand command);
|
||||
}
|
||||
|
||||
+218
-19
@@ -21,8 +21,7 @@ package org.apache.fineract.organisation.agent.service;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import jakarta.persistence.PersistenceException;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -34,13 +33,24 @@ import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
||||
import org.apache.fineract.organisation.agent.domain.AgentGuarantor;
|
||||
import org.apache.fineract.organisation.agent.domain.AgentLimit;
|
||||
import org.apache.fineract.organisation.agent.domain.AgentRepository;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentCannotBeClosedException;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentSavingsAccountClientMismatchException;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentSavingsAccountNotFoundException;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentSavingsAccountNotOperativeException;
|
||||
import org.apache.fineract.organisation.agent.serialization.AgentCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentGlAccountUnusableException;
|
||||
import org.apache.fineract.organisation.office.domain.Office;
|
||||
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.orm.jpa.JpaSystemException;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -51,6 +61,10 @@ public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePla
|
||||
private final AgentCommandFromApiJsonDeserializer fromApiJsonDeserializer;
|
||||
private final AgentRepository agentRepository;
|
||||
private final ClientRepositoryWrapper clientRepositoryWrapper;
|
||||
private final OfficeRepositoryWrapper officeRepositoryWrapper;
|
||||
private final GLAccountRepository glAccountRepository;
|
||||
private final PartyRepository partyRepository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -62,10 +76,12 @@ public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePla
|
||||
final Long clientId = command.longValueOfParameterNamed("clientId");
|
||||
final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
|
||||
|
||||
validateSavingsAccount(clientId, command);
|
||||
|
||||
final String agentCode = generateNextAgentCode();
|
||||
final Agent agent = Agent.fromJson(client, agentCode, command);
|
||||
final Agent agent = Agent.fromJson(client, agentCode, officeOf(client, command), command);
|
||||
agent.setGlAccount(glAccountOf(command, null));
|
||||
|
||||
addLimitsFromCommand(agent, command);
|
||||
addGuarantorsFromCommand(agent, command);
|
||||
|
||||
this.agentRepository.saveAndFlush(agent);
|
||||
@@ -92,18 +108,32 @@ public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePla
|
||||
this.fromApiJsonDeserializer.validateForUpdate(command.json());
|
||||
|
||||
final Agent agentForUpdate = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
|
||||
|
||||
// Checked against whichever client the agent will belong to after this update, not the
|
||||
// one they belonged to before - otherwise moving an agent to a new client would carry the
|
||||
// old client's account across unchallenged.
|
||||
final Long owningClientId = command.parameterExists("clientId") ? command.longValueOfParameterNamed("clientId")
|
||||
: agentForUpdate.clientId();
|
||||
validateSavingsAccount(owningClientId, command);
|
||||
|
||||
final Map<String, Object> changesOnly = agentForUpdate.update(command);
|
||||
|
||||
if (changesOnly.containsKey("clientId")) {
|
||||
final Long newClientId = (Long) changesOnly.get("clientId");
|
||||
final Client newClient = this.clientRepositoryWrapper.findOneWithNotFoundDetection(newClientId);
|
||||
agentForUpdate.changeClient(newClient);
|
||||
// The branch follows the client unless the caller named one - see officeOf.
|
||||
agentForUpdate.setOffice(officeOf(newClient, command));
|
||||
changesOnly.put("officeId", agentForUpdate.officeId());
|
||||
} else if (command.isChangeInLongParameterNamed("officeId", agentForUpdate.officeId())) {
|
||||
final Long newOfficeId = command.longValueOfParameterNamed("officeId");
|
||||
agentForUpdate.setOffice(this.officeRepositoryWrapper.findOneWithNotFoundDetection(newOfficeId));
|
||||
changesOnly.put("officeId", newOfficeId);
|
||||
}
|
||||
|
||||
if (command.parameterExists("limits")) {
|
||||
agentForUpdate.resetLimits();
|
||||
addLimitsFromCommand(agentForUpdate, command);
|
||||
changesOnly.put("limits", command.jsonFragment("limits"));
|
||||
if (command.isChangeInLongParameterNamed("glAccountId", agentForUpdate.glAccountId())) {
|
||||
agentForUpdate.setGlAccount(glAccountOf(command, agentForUpdate.getId()));
|
||||
changesOnly.put("glAccountId", agentForUpdate.glAccountId());
|
||||
}
|
||||
|
||||
if (command.parameterExists("guarantors")) {
|
||||
@@ -126,20 +156,189 @@ public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePla
|
||||
}
|
||||
}
|
||||
|
||||
private void addLimitsFromCommand(final Agent agent, final JsonCommand command) {
|
||||
if (!command.parameterExists("limits")) {
|
||||
/**
|
||||
* Ends the relationship with an agent, refusing while anything is still outstanding.
|
||||
*
|
||||
* The three conditions are the three things that would be left with nobody responsible for them:
|
||||
* accounts still being collected from, cash the agent holds that has not reached a branch, and
|
||||
* commission earned but not paid. Each is reported by name and figure, so whoever is closing the
|
||||
* agent knows exactly what to settle first rather than being told only that it failed.
|
||||
*
|
||||
* Deactivating an agent is deliberately not guarded this way - see
|
||||
* {@link org.apache.fineract.organisation.agent.exception.AgentCannotBeClosedException}.
|
||||
*/
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult closeAgent(final Long agentId, final JsonCommand command) {
|
||||
|
||||
final Agent agent = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
|
||||
|
||||
if (agent.isClosed()) {
|
||||
throw new AgentCannotBeClosedException(agent.getAgentCode(), "it is already closed");
|
||||
}
|
||||
|
||||
final Long openAccounts = this.partyRepository.countActivePartiesOfAgent(agentId);
|
||||
if (openAccounts != null && openAccounts > 0) {
|
||||
throw new AgentCannotBeClosedException(agent.getAgentCode(), openAccounts
|
||||
+ (openAccounts == 1 ? " account is" : " accounts are")
|
||||
+ " still assigned to them. Move them to another agent first.");
|
||||
}
|
||||
|
||||
if (agent.holdsCash()) {
|
||||
throw new AgentCannotBeClosedException(agent.getAgentCode(), "they still hold "
|
||||
+ agent.getAgentCurrentBalance().toPlainString()
|
||||
+ " in collected cash. Settle it to a branch first (Agent Tran).");
|
||||
}
|
||||
|
||||
if (agent.isOwedCommission()) {
|
||||
throw new AgentCannotBeClosedException(agent.getAgentCode(),
|
||||
"they are still owed " + agent.getCommission().toPlainString()
|
||||
+ " in commission. Pay it out first (Commission Posting).");
|
||||
}
|
||||
|
||||
agent.close(DateUtils.getBusinessLocalDate());
|
||||
this.agentRepository.saveAndFlush(agent);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(agentId).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Which branch this agent belongs to.
|
||||
*
|
||||
* The client's own office is the default, since that is where the person is on the bank's books.
|
||||
* A caller may still name a branch, because an agent can be registered at one office and collect
|
||||
* out of another - the batch runs filter by this, so it has to say where the agent actually
|
||||
* works rather than where their client record happens to sit.
|
||||
*
|
||||
* Resolved to an actual office row rather than taken as a name: an unknown id is refused here,
|
||||
* where a name that matched no branch used to be stored as happily as one that did.
|
||||
*/
|
||||
/**
|
||||
* The ledger account this agent's collections are to pass through, or null to leave them on the
|
||||
* scheme's shared one.
|
||||
*
|
||||
* Four things are asked of it, and each has a way of going wrong that would not show up until
|
||||
* money was moving. It has to exist. It has to be a detail account, because a header account is
|
||||
* a heading over others and carries no entries of its own. It has to be enabled, or every
|
||||
* posting through it would be refused. And it has to belong to no other agent, since two agents
|
||||
* on one account is the shared account again under a different name - the balance would say what
|
||||
* is outstanding without saying whose.
|
||||
*
|
||||
* What it deliberately does not check is the account's classification. Cash held by an agent is
|
||||
* an asset and the branch will almost always pick one, but where a bank chooses to carry it is
|
||||
* the bank's own accounting policy, and this is not the place to overrule it.
|
||||
*
|
||||
* @param forAgentId
|
||||
* the agent being changed, so an agent keeping its own account is not refused for
|
||||
* already holding it. Null when the agent does not exist yet.
|
||||
*/
|
||||
private GLAccount glAccountOf(final JsonCommand command, final Long forAgentId) {
|
||||
if (!command.parameterExists("glAccountId")) {
|
||||
return null;
|
||||
}
|
||||
final Long glAccountId = command.longValueOfParameterNamed("glAccountId");
|
||||
if (glAccountId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final GLAccount glAccount = this.glAccountRepository.findById(glAccountId)
|
||||
.orElseThrow(() -> new AgentGlAccountUnusableException(String.valueOf(glAccountId), "no such ledger account exists"));
|
||||
|
||||
if (glAccount.isHeaderAccount()) {
|
||||
throw new AgentGlAccountUnusableException(glAccount.getGlCode(), "it is a header account and carries no entries of its own");
|
||||
}
|
||||
if (glAccount.isDisabled()) {
|
||||
throw new AgentGlAccountUnusableException(glAccount.getGlCode(), "it is disabled");
|
||||
}
|
||||
|
||||
final Agent holder = this.agentRepository.findByGlAccountId(glAccountId);
|
||||
if (holder != null && !holder.getId().equals(forAgentId)) {
|
||||
throw new AgentGlAccountUnusableException(glAccount.getGlCode(), "agent " + holder.getAgentCode() + " already uses it");
|
||||
}
|
||||
|
||||
return glAccount;
|
||||
}
|
||||
|
||||
private Office officeOf(final Client client, final JsonCommand command) {
|
||||
if (command.parameterExists("officeId")) {
|
||||
final Long officeId = command.longValueOfParameterNamed("officeId");
|
||||
if (officeId != null) {
|
||||
return this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
|
||||
}
|
||||
}
|
||||
return client.getOffice();
|
||||
}
|
||||
|
||||
/**
|
||||
* The open states of a savings account, matching SavingsAccountRepository's own "non closed" set.
|
||||
* deposit_type_enum comes back too: 100 is a savings account, 200 a fixed deposit, 300 recurring.
|
||||
*/
|
||||
/**
|
||||
* The account an agent's commission is to be paid into, and enough about it to say whether it
|
||||
* can take one.
|
||||
*
|
||||
* Active only. An account still awaiting approval, or approved but never activated, takes no
|
||||
* credit at all - and one accepted here would fail silently on every commission run from then
|
||||
* on, leaving the agent unpaid with nothing on screen to say why.
|
||||
*/
|
||||
private static final String OPEN_ACCOUNT_SQL = "select a.client_id, p.deposit_type_enum, a.product_id from m_savings_account a "
|
||||
+ "join m_savings_product p on p.id = a.product_id "
|
||||
+ "where a.account_no = ? and a.status_enum = 300";
|
||||
|
||||
/** The product DDS opens customers' scheme accounts under - see 0492. */
|
||||
private static final String DDS_DEPOSIT_PRODUCT_SQL = "select value from c_configuration "
|
||||
+ "where name = 'dds-deposit-product' and enabled = true";
|
||||
|
||||
private static final int SAVINGS_DEPOSIT_TYPE = 100;
|
||||
|
||||
/**
|
||||
* Refuses a savings account that does not exist, or that belongs to somebody else.
|
||||
*
|
||||
* The agent's commission is paid into this account. Until this ran the number was free text, and
|
||||
* the live data showed what that costs: agent AG0001 carried "1234567890", which matches no
|
||||
* account in the database at all - so every commission recorded against it had nowhere to go.
|
||||
*
|
||||
* Enforced here rather than only on the screen, because the API can be called directly.
|
||||
*
|
||||
* Reads the owner's id rather than loading the account entity. Loading it pulled its product in
|
||||
* too, and saving the agent afterwards then failed for a fixed-deposit account with "The
|
||||
* attribute [id] of class FixedDepositProduct ... Updates are not allowed" - the agent's own
|
||||
* insert flushing a savings product it had merely looked at. Two columns are all this check
|
||||
* needs, and reading them touches no entity the transaction can try to write back.
|
||||
*/
|
||||
private void validateSavingsAccount(final Long expectedClientId, final JsonCommand command) {
|
||||
if (!command.parameterExists("agentSavingAccountNo")) {
|
||||
return;
|
||||
}
|
||||
final JsonArray limitsArray = command.arrayOfParameterNamed("limits");
|
||||
if (limitsArray == null) {
|
||||
final String accountNo = command.stringValueOfParameterNamed("agentSavingAccountNo");
|
||||
if (accountNo == null || accountNo.isBlank()) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < limitsArray.size(); i++) {
|
||||
final JsonObject limitJson = limitsArray.get(i).getAsJsonObject();
|
||||
final LocalDate fromDate = LocalDate.parse(limitJson.get("fromDate").getAsString());
|
||||
final LocalDate toDate = LocalDate.parse(limitJson.get("toDate").getAsString());
|
||||
final BigDecimal limit = limitJson.get("limit").getAsBigDecimal();
|
||||
agent.addLimit(new AgentLimit(fromDate, toDate, limit));
|
||||
|
||||
final List<Map<String, Object>> accounts = this.jdbcTemplate.queryForList(OPEN_ACCOUNT_SQL, accountNo); // NOSONAR
|
||||
if (accounts.isEmpty()) {
|
||||
throw new AgentSavingsAccountNotFoundException(accountNo);
|
||||
}
|
||||
|
||||
final Map<String, Object> account = accounts.get(0);
|
||||
final Long ownerId = ((Number) account.get("client_id")).longValue();
|
||||
if (!expectedClientId.equals(ownerId)) {
|
||||
throw new AgentSavingsAccountClientMismatchException(accountNo);
|
||||
}
|
||||
|
||||
// Commission is credited whenever a run posts, which only a savings account accepts.
|
||||
final int depositType = ((Number) account.get("deposit_type_enum")).intValue();
|
||||
if (depositType != SAVINGS_DEPOSIT_TYPE) {
|
||||
throw new AgentSavingsAccountNotOperativeException(accountNo, depositType == 200 ? "fixed deposit" : "recurring deposit");
|
||||
}
|
||||
|
||||
// A DDS scheme account is a savings account too, so the check above lets it through - but it
|
||||
// belongs to a customer's scheme, and commission paid into it would be counted as that
|
||||
// customer's deposits by every DDS interest run from then on.
|
||||
final Long productId = ((Number) account.get("product_id")).longValue();
|
||||
final List<Long> ddsProduct = this.jdbcTemplate.queryForList(DDS_DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR
|
||||
if (!ddsProduct.isEmpty() && productId.equals(ddsProduct.get(0))) {
|
||||
throw new AgentSavingsAccountNotOperativeException(accountNo, "DDS scheme");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -18,13 +18,16 @@
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.starter;
|
||||
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
|
||||
import org.apache.fineract.organisation.agent.domain.AgentRepository;
|
||||
import org.apache.fineract.organisation.agent.serialization.AgentCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.organisation.agent.service.AgentReadPlatformService;
|
||||
import org.apache.fineract.organisation.agent.service.AgentReadPlatformServiceImpl;
|
||||
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
|
||||
import org.apache.fineract.organisation.agent.service.AgentWritePlatformServiceJpaRepositoryImpl;
|
||||
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -42,7 +45,10 @@ public class OrganisationAgentConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(AgentWritePlatformService.class)
|
||||
public AgentWritePlatformService agentWritePlatformService(AgentCommandFromApiJsonDeserializer fromApiJsonDeserializer,
|
||||
AgentRepository agentRepository, ClientRepositoryWrapper clientRepositoryWrapper) {
|
||||
return new AgentWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, agentRepository, clientRepositoryWrapper);
|
||||
AgentRepository agentRepository, ClientRepositoryWrapper clientRepositoryWrapper,
|
||||
OfficeRepositoryWrapper officeRepositoryWrapper, GLAccountRepository glAccountRepository, PartyRepository partyRepository,
|
||||
JdbcTemplate jdbcTemplate) {
|
||||
return new AgentWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, agentRepository, clientRepositoryWrapper,
|
||||
officeRepositoryWrapper, glAccountRepository, partyRepository, jdbcTemplate);
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -24,7 +24,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.Collection;
|
||||
@@ -71,4 +73,20 @@ public class CommissionSlabsApiResource {
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("{slabId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Correct a DDS Commission Slab", description = "Optional fields: slabType, slabFrom, slabTo, "
|
||||
+ "commissionPercent. The corrected range is re-checked against the other slabs of its type and refused if it "
|
||||
+ "overlaps one of them.")
|
||||
public CommandProcessingResult update(@PathParam("slabId") @Parameter(description = "slabId") final Long slabId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.updateCommissionSlab(slabId) //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -28,4 +28,7 @@ public interface CommissionSlabRepository extends JpaRepository<CommissionSlab,
|
||||
|
||||
@Query("select s from CommissionSlab s where s.slabType = :slabType and :amount between s.slabFrom and s.slabTo")
|
||||
List<CommissionSlab> findMatchingSlabs(@Param("slabType") String slabType, @Param("amount") BigDecimal amount);
|
||||
|
||||
@Query("select s from CommissionSlab s where s.slabType = :slabType order by s.slabFrom")
|
||||
List<CommissionSlab> findByType(@Param("slabType") String slabType);
|
||||
}
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.commissionslab.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "COMMISSIONSLAB", action = "UPDATE")
|
||||
public class UpdateCommissionSlabCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final CommissionSlabWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public UpdateCommissionSlabCommandHandler(final CommissionSlabWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.updateCommissionSlab(command.entityId(), command);
|
||||
}
|
||||
}
|
||||
+46
-2
@@ -41,10 +41,14 @@ import org.springframework.stereotype.Component;
|
||||
@Component
|
||||
public final class CommissionSlabCommandFromApiJsonDeserializer {
|
||||
|
||||
private static final Set<String> SUPPORTED_SLAB_TYPES = new HashSet<>(Arrays.asList("ACCOUNT", "COMMISSION"));
|
||||
// A List, not a Set: isOneOfTheseStringValues only lowercases both sides in its List overload.
|
||||
private static final List<String> SUPPORTED_SLAB_TYPES = List.of("ACCOUNT", "COMMISSION");
|
||||
|
||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("slabs", "dateFormat", "locale"));
|
||||
|
||||
private static final Set<String> SUPPORTED_UPDATE_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList("slabType", "slabFrom", "slabTo", "commissionPercent", "dateFormat", "locale"));
|
||||
|
||||
private static final Set<String> SUPPORTED_SLAB_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList("slabType", "slabFrom", "slabTo", "commissionPercent"));
|
||||
|
||||
@@ -83,7 +87,7 @@ public final class CommissionSlabCommandFromApiJsonDeserializer {
|
||||
|
||||
final String slabType = extractString(slabJson, "slabType");
|
||||
v.reset().parameterAtIndexArray("slabType", i).value(slabType).notBlank()
|
||||
.isOneOfTheseStringValues(SUPPORTED_SLAB_TYPES.toArray());
|
||||
.isOneOfTheseStringValues(SUPPORTED_SLAB_TYPES);
|
||||
v.reset().parameterAtIndexArray("slabFrom", i).value(extractBigDecimal(slabJson, "slabFrom")).notNull()
|
||||
.zeroOrPositiveAmount();
|
||||
v.reset().parameterAtIndexArray("slabTo", i).value(extractBigDecimal(slabJson, "slabTo")).notNull()
|
||||
@@ -96,6 +100,46 @@ public final class CommissionSlabCommandFromApiJsonDeserializer {
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
}
|
||||
|
||||
/**
|
||||
* A correction to one existing slab - a flat object, not the array the create endpoint takes,
|
||||
* because only one row is ever being fixed.
|
||||
*/
|
||||
public void validateForUpdate(final String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
throw new InvalidJsonException();
|
||||
}
|
||||
|
||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
|
||||
|
||||
}.getType();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_UPDATE_PARAMETERS);
|
||||
|
||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("commissionslab");
|
||||
|
||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists("slabType", element)) {
|
||||
v.reset().parameter("slabType").value(this.fromApiJsonHelper.extractStringNamed("slabType", element)).notBlank()
|
||||
.isOneOfTheseStringValues(SUPPORTED_SLAB_TYPES);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("slabFrom", element)) {
|
||||
v.reset().parameter("slabFrom").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("slabFrom", element))
|
||||
.notNull().zeroOrPositiveAmount();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("slabTo", element)) {
|
||||
v.reset().parameter("slabTo").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("slabTo", element)).notNull()
|
||||
.zeroOrPositiveAmount();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("commissionPercent", element)) {
|
||||
v.reset().parameter("commissionPercent")
|
||||
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("commissionPercent", element)).notNull()
|
||||
.zeroOrPositiveAmount();
|
||||
}
|
||||
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
}
|
||||
|
||||
private String extractString(final JsonObject json, final String paramName) {
|
||||
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsString() : null;
|
||||
}
|
||||
|
||||
+2
@@ -24,4 +24,6 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
public interface CommissionSlabWritePlatformService {
|
||||
|
||||
CommandProcessingResult createCommissionSlabs(JsonCommand command);
|
||||
|
||||
CommandProcessingResult updateCommissionSlab(Long slabId, JsonCommand command);
|
||||
}
|
||||
|
||||
+56
-2
@@ -23,6 +23,7 @@ import com.google.gson.JsonObject;
|
||||
import jakarta.persistence.PersistenceException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||
@@ -32,6 +33,8 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuild
|
||||
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
|
||||
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlab;
|
||||
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
|
||||
import org.apache.fineract.organisation.commissionslab.exception.CommissionSlabNotFoundException;
|
||||
import org.apache.fineract.organisation.commissionslab.exception.OverlappingCommissionSlabException;
|
||||
import org.apache.fineract.organisation.commissionslab.serialization.CommissionSlabCommandFromApiJsonDeserializer;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.orm.jpa.JpaSystemException;
|
||||
@@ -55,8 +58,14 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
|
||||
final JsonArray slabsArray = command.arrayOfParameterNamed("slabs");
|
||||
for (int i = 0; i < slabsArray.size(); i++) {
|
||||
final JsonObject slabJson = slabsArray.get(i).getAsJsonObject();
|
||||
slabs.add(new CommissionSlab(slabJson.get("slabType").getAsString(), slabJson.get("slabFrom").getAsBigDecimal(),
|
||||
slabJson.get("slabTo").getAsBigDecimal(), slabJson.get("commissionPercent").getAsBigDecimal()));
|
||||
final CommissionSlab slab = new CommissionSlab(slabJson.get("slabType").getAsString(),
|
||||
slabJson.get("slabFrom").getAsBigDecimal(), slabJson.get("slabTo").getAsBigDecimal(),
|
||||
slabJson.get("commissionPercent").getAsBigDecimal());
|
||||
// Checked against what is already stored and against the rest of this request, since one
|
||||
// submission can just as easily carry two slabs that cover the same figure.
|
||||
rejectIfOverlapping(slab, this.commissionSlabRepository.findByType(slab.getSlabType()));
|
||||
rejectIfOverlapping(slab, slabs);
|
||||
slabs.add(slab);
|
||||
}
|
||||
|
||||
this.commissionSlabRepository.saveAllAndFlush(slabs);
|
||||
@@ -75,4 +84,49 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
|
||||
"Unknown data integrity issue with resource: " + ExceptionUtils.getRootCauseMessage(dve));
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult updateCommissionSlab(final Long slabId, final JsonCommand command) {
|
||||
|
||||
this.fromApiJsonDeserializer.validateForUpdate(command.json());
|
||||
|
||||
final CommissionSlab slab = this.commissionSlabRepository.findById(slabId)
|
||||
.orElseThrow(() -> new CommissionSlabNotFoundException(slabId));
|
||||
final Map<String, Object> changes = slab.update(command);
|
||||
|
||||
// Re-checked after the change, against every other slab of its type. A correction can just as
|
||||
// easily create an overlap as a new row can - and the slab being corrected must not be
|
||||
// compared with itself.
|
||||
if (!changes.isEmpty()) {
|
||||
final List<CommissionSlab> others = this.commissionSlabRepository.findByType(slab.getSlabType()).stream()
|
||||
.filter(other -> !other.getId().equals(slab.getId())).toList();
|
||||
rejectIfOverlapping(slab, others);
|
||||
this.commissionSlabRepository.saveAndFlush(slab);
|
||||
}
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(slabId).with(changes).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a slab whose range overlaps one already covering part of the same figures.
|
||||
*
|
||||
* Two slabs matching the same collection volume leave the rate up to whichever row the query
|
||||
* returns first, so an agent's percentage could change between runs with nothing in the data to
|
||||
* explain it. Ranges are inclusive at both ends, so touching endpoints count as an overlap: a
|
||||
* slab ending at 50000 and the next starting at 50000 both match exactly 50000.
|
||||
*/
|
||||
private void rejectIfOverlapping(final CommissionSlab candidate, final List<CommissionSlab> existing) {
|
||||
for (final CommissionSlab other : existing) {
|
||||
if (!other.getSlabType().equals(candidate.getSlabType())) {
|
||||
continue;
|
||||
}
|
||||
final boolean overlaps = candidate.getSlabFrom().compareTo(other.getSlabTo()) <= 0
|
||||
&& candidate.getSlabTo().compareTo(other.getSlabFrom()) >= 0;
|
||||
if (overlaps) {
|
||||
throw new OverlappingCommissionSlabException(candidate.getSlabType(), candidate.getSlabFrom(),
|
||||
candidate.getSlabTo(), other.getSlabFrom(), other.getSlabTo());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+416
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounting;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
|
||||
import org.apache.fineract.accounting.journalentry.domain.JournalEntry;
|
||||
import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository;
|
||||
import org.apache.fineract.accounting.journalentry.domain.JournalEntryType;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.organisation.office.domain.Office;
|
||||
import org.apache.fineract.portfolio.PortfolioProductType;
|
||||
import org.apache.fineract.portfolio.dds.accounting.exception.DdsLedgerNotConfiguredException;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountSubStatusEnum;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
|
||||
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Where DDS money lands in the bank's own books.
|
||||
*
|
||||
* DDS used to keep its own balances and nothing else: a customer's money, an agent's commission and
|
||||
* the cash an agent was carrying were all numbers on DDS tables that the bank's books never saw. This
|
||||
* is the one place that changes - every DDS movement that is real money goes through here, becomes a
|
||||
* transaction on a real account, and leaves a matching pair of journal entries behind.
|
||||
*
|
||||
* <p>
|
||||
* The five movements and the entries each makes:
|
||||
*
|
||||
* <pre>
|
||||
* Agent collects from a customer DR Agent Cash CR the account's control
|
||||
* Agent settles at a branch DR Cash at Vault CR Agent Cash
|
||||
* One customer pays another DR their control CR the other's control
|
||||
* Interest is posted DR Interest on Savings CR the account's control
|
||||
* Commission is paid DR Commission Expense CR the account's control
|
||||
* </pre>
|
||||
*
|
||||
* The savings module's own accounting is deliberately skipped on these deposits. Left to itself it
|
||||
* debits the product's fund source - the entry an ordinary counter deposit makes, "cash came in over
|
||||
* the counter". None of these is that: a collection is cash that reached an agent rather than a till,
|
||||
* and interest and commission are expenses the bank incurred with no cash arriving at all. So the
|
||||
* deposit is recorded without its accounting and the true pair is written here instead.
|
||||
*
|
||||
* <p>
|
||||
* Every account is looked up rather than named in code - the ledger accounts from settings a bank can
|
||||
* repoint at accounts of their own, and each customer's control account from the product's own
|
||||
* mapping. Nothing here is written if any of them is missing: it throws, and the caller's whole
|
||||
* transaction rolls back. Money that moves without an entry behind it is worse than money that has
|
||||
* not moved, because the balance changes and the books do not.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DdsLedger {
|
||||
|
||||
/** Cash an agent has collected but not yet handed to a branch. Set by 0493. */
|
||||
public static final String AGENT_CASH = "dds-agent-cash-account";
|
||||
|
||||
/** What an agent's commission costs the bank. Set by 0491. */
|
||||
public static final String COMMISSION_EXPENSE = "dds-agent-commission-expense-account";
|
||||
|
||||
/** The savings product DDS opens customer accounts under. Set by 0492. */
|
||||
public static final String DEPOSIT_PRODUCT = "dds-deposit-product";
|
||||
|
||||
/** Where a matured deposit waits while it is on its way to another bank. Set by 0494. */
|
||||
public static final String MATURED_PAYABLE = "dds-matured-payable-account";
|
||||
|
||||
private static final String PRODUCT_CURRENCY_SQL = "select currency_code from m_savings_product where id = ?";
|
||||
|
||||
private static final String CONFIGURED_ACCOUNT_SQL = "select value from c_configuration where name = ? and enabled = true";
|
||||
|
||||
/**
|
||||
* product_type 2 is savings. financial_account_type 2 is the control account - what the customer
|
||||
* is owed - and 1 is the fund source, the till or vault cash moves through.
|
||||
*/
|
||||
private static final String PRODUCT_ACCOUNT_SQL = "select gl_account_id from acc_product_mapping "
|
||||
+ "where product_id = ? and product_type = 2 and financial_account_type = ? and payment_type is null";
|
||||
|
||||
private static final int CONTROL_ACCOUNT = 2;
|
||||
private static final int FUND_SOURCE = 1;
|
||||
|
||||
/** What interest paid to a customer costs the bank. */
|
||||
private static final int INTEREST_ON_SAVINGS = 3;
|
||||
|
||||
private final SavingsAccountAssembler savingsAccountAssembler;
|
||||
private final SavingsAccountDomainService savingsAccountDomainService;
|
||||
private final JournalEntryRepository journalEntryRepository;
|
||||
private final GLAccountRepository glAccountRepository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* Credits a customer's own deposit account with what an agent collected from them.
|
||||
*
|
||||
* The money is real from the customer's side the moment the agent takes it, so it lands on their
|
||||
* account now rather than when the agent gets round to a branch. What the bank does not have yet
|
||||
* is the cash itself - it is in the agent's bag - which is what the Agent Cash side records.
|
||||
*/
|
||||
public SavingsAccountTransaction collect(final Long savingsAccountId, final LocalDate date, final BigDecimal amount,
|
||||
final String description, final GLAccount agentCashAccount) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
||||
postPair(account, deposit, date, amount, agentCashOr(agentCashAccount), controlAccountOf(account), description);
|
||||
return deposit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves cash from an agent's hands into a branch. No customer account is involved - the customers
|
||||
* were credited when their money was collected; this is the bank catching up with the cash.
|
||||
*/
|
||||
public void settle(final Office office, final LocalDate date, final BigDecimal amount, final String description,
|
||||
final GLAccount agentCashAccount) {
|
||||
final Long productId = depositProductId();
|
||||
postPair(office, currencyOf(productId), date, amount, productAccount(productId, FUND_SOURCE), agentCashOr(agentCashAccount),
|
||||
description, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which account plays the agent's part on an entry: the agent's own, or the scheme's shared one
|
||||
* when they have none.
|
||||
*
|
||||
* Only the account differs. Both sides of the entry, their direction and the amount are the same
|
||||
* either way - an agent with their own account is not accounted for differently, only separately.
|
||||
* That is what makes the shared account a safe fallback rather than a second behaviour: an agent
|
||||
* set up before this existed posts exactly as they always did, and a branch can move agents onto
|
||||
* their own accounts one at a time.
|
||||
*/
|
||||
private GLAccount agentCashOr(final GLAccount agentCashAccount) {
|
||||
return agentCashAccount != null ? agentCashAccount : configured(AGENT_CASH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits a customer's account with interest their deposits have earned.
|
||||
*
|
||||
* The interest is worked out by the DDS run on the daily product, not by the savings product -
|
||||
* which is why the product carries a zero rate - but it is paid like any other interest: an
|
||||
* expense to the bank, and more owed to the customer.
|
||||
*/
|
||||
public SavingsAccountTransaction postInterest(final Long savingsAccountId, final LocalDate date, final BigDecimal amount,
|
||||
final String description) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
||||
postPair(account, deposit, date, amount, productAccount(account.productId(), INTEREST_ON_SAVINGS), controlAccountOf(account),
|
||||
description);
|
||||
return deposit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes back interest a customer has been credited but, on closing early, is not entitled to.
|
||||
*
|
||||
* The exact opposite of {@link #postInterest}: the bank owes the customer less, and its interest
|
||||
* expense is reduced by what it turned out not to owe. It is written as its own movement rather
|
||||
* than by quietly paying out less, so the passbook shows the interest that was credited and the
|
||||
* part that came back - a customer who is paid a smaller figure than their last statement showed
|
||||
* can see exactly why.
|
||||
*
|
||||
* <pre>
|
||||
* DR the account's control the customer's balance comes down
|
||||
* CR Interest on Savings the bank's interest expense is reduced
|
||||
* </pre>
|
||||
*/
|
||||
public void reverseInterest(final Long savingsAccountId, final LocalDate date, final BigDecimal amount, final String description) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
|
||||
// DDS's own movement, so the debit block comes off for it and goes straight back on.
|
||||
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
||||
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
|
||||
this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
relockDebits(account, wasBlocked);
|
||||
|
||||
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account),
|
||||
productAccount(account.productId(), INTEREST_ON_SAVINGS), description, null, account.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves money from one customer's account to another's.
|
||||
*
|
||||
* The bank is no better or worse off - it owed the money before and owes it after, just to
|
||||
* somebody else - so the two halves fall on the same control account and cancel. What changes is
|
||||
* which customer's balance holds it, and that is what the two account transactions record.
|
||||
*/
|
||||
public void transfer(final Long fromSavingsAccountId, final Long toSavingsAccountId, final LocalDate date, final BigDecimal amount,
|
||||
final String description) {
|
||||
final SavingsAccount from = load(fromSavingsAccountId);
|
||||
final SavingsAccount to = load(toSavingsAccountId);
|
||||
|
||||
// The scheme account is blocked for debit so nobody can take the money out at a counter. This
|
||||
// movement is DDS's own, so the block comes off for it and goes straight back on.
|
||||
final boolean wasBlocked = unlockDebitsIfBlocked(from);
|
||||
final SavingsTransactionBooleanValues withdrawalFlags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||
this.savingsAccountDomainService.handleWithdrawal(from, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, withdrawalFlags,
|
||||
this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
relockDebits(from, wasBlocked);
|
||||
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(to, date, amount);
|
||||
|
||||
postPair(to, deposit, date, amount, controlAccountOf(from), controlAccountOf(to), description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pays a matured deposit into another account of the customer's at this bank.
|
||||
*
|
||||
* The whole balance leaves the DDS account and lands on theirs. Both are the bank's own accounts,
|
||||
* so nothing leaves the bank - it owes the same money to the same person, in a different place -
|
||||
* which is why this is the same movement as a transfer between two customers.
|
||||
*/
|
||||
public void payOutToOwnAccount(final Long fromSavingsAccountId, final Long toSavingsAccountId, final LocalDate date,
|
||||
final BigDecimal amount, final String description) {
|
||||
transfer(fromSavingsAccountId, toSavingsAccountId, date, amount, description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a matured deposit off the customer's account and parks it, ready to be sent to another
|
||||
* bank.
|
||||
*
|
||||
* The money leaves the DDS account today; the NEFT or IMPS clears later, or fails and is tried
|
||||
* again. Sending it straight out would leave a window where it has left one account and arrived
|
||||
* at none, and a failed transfer would leave nothing accounting for it at all. So it waits in the
|
||||
* payable account, which says at any moment exactly what the bank is holding and still owes.
|
||||
*
|
||||
* <pre>
|
||||
* DR the account's control the customer's balance goes
|
||||
* CR Matured Deposits Payable and the bank now owes it to them
|
||||
* </pre>
|
||||
*/
|
||||
public void payOutToPayable(final Long savingsAccountId, final LocalDate date, final BigDecimal amount, final String description) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
|
||||
// DDS's own movement, so the debit block comes off for it and goes straight back on.
|
||||
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
||||
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
|
||||
this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
relockDebits(account, wasBlocked);
|
||||
|
||||
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), configured(MATURED_PAYABLE),
|
||||
description, null, account.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings a balance a party already had onto its newly opened account.
|
||||
*
|
||||
* These are customers whose money DDS held before it opened real accounts: the bank has the cash -
|
||||
* it was collected and banked over months - but its own books carry no record of either the cash
|
||||
* or what it owes them. This writes both sides at once, which is what an opening entry is for.
|
||||
*
|
||||
* <pre>
|
||||
* DR the product's fund source the cash the bank already holds
|
||||
* CR the account's control what it owes the customer
|
||||
* </pre>
|
||||
*
|
||||
* The debit assumes the cash reached the bank rather than still sitting with an agent. That holds
|
||||
* for a settled balance and is the ordinary case; a bank whose agents were mid-round when this ran
|
||||
* should reconcile it. It is the one assumption here that an accountant, not this code, should
|
||||
* confirm.
|
||||
*/
|
||||
public SavingsAccountTransaction openingBalance(final Long savingsAccountId, final LocalDate date, final BigDecimal amount,
|
||||
final String description) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
||||
postPair(account, deposit, date, amount, productAccount(account.productId(), FUND_SOURCE), controlAccountOf(account), description);
|
||||
return deposit;
|
||||
}
|
||||
|
||||
/** Credits an agent's own savings account with commission the bank owes them. */
|
||||
public SavingsAccountTransaction payCommission(final Long savingsAccountId, final LocalDate date, final BigDecimal amount,
|
||||
final String description) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
||||
postPair(account, deposit, date, amount, configured(COMMISSION_EXPENSE), controlAccountOf(account), description);
|
||||
return deposit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks withdrawals on a scheme account, leaving deposits alone.
|
||||
*
|
||||
* A DDS account is a savings account, and without this a customer can empty a twelve-month
|
||||
* scheme at the counter the day after opening it - which is the one thing a daily deposit scheme
|
||||
* exists to prevent. Worse, that withdrawal happens outside DDS: the scheme balance and the
|
||||
* account balance part company, every report misstates the book, and the maturity payout later
|
||||
* fails for want of funds.
|
||||
*
|
||||
* Deposits are untouched, so the agent collects as before and interest posts as before. Only the
|
||||
* way out is closed, and only from outside - DDS lifts it for its own payouts and transfers.
|
||||
*/
|
||||
public void lockDebits(final Long savingsAccountId) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
if (!isDebitBlocked(account)) {
|
||||
account.blockDebits(account.getSubStatus());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifts the block for a movement DDS is making itself, saying whether it had to.
|
||||
*
|
||||
* The caller puts it back with {@link #relockDebits}. An account that was not blocked - one
|
||||
* opened before the block existed, say - is left as it is rather than being blocked for the
|
||||
* first time on its way out of the scheme.
|
||||
*/
|
||||
private boolean unlockDebitsIfBlocked(final SavingsAccount account) {
|
||||
if (!isDebitBlocked(account)) {
|
||||
return false;
|
||||
}
|
||||
account.unblockDebits();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Puts the block back after one of DDS's own withdrawals, if this call was what lifted it. */
|
||||
private void relockDebits(final SavingsAccount account, final boolean wasBlocked) {
|
||||
if (wasBlocked) {
|
||||
account.blockDebits(account.getSubStatus());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isDebitBlocked(final SavingsAccount account) {
|
||||
return SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(SavingsAccountSubStatusEnum.fromInt(account.getSubStatus()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
private SavingsAccount load(final Long savingsAccountId) {
|
||||
return this.savingsAccountAssembler.assembleFrom(savingsAccountId, this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
}
|
||||
|
||||
private SavingsAccountTransaction depositWithoutAccounting(final SavingsAccount account, final LocalDate date,
|
||||
final BigDecimal amount) {
|
||||
return this.savingsAccountDomainService.handleDeposit(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, false, true,
|
||||
this.savingsAccountAssembler.getPivotConfigStatus(), true);
|
||||
}
|
||||
|
||||
private void postPair(final SavingsAccount account, final SavingsAccountTransaction deposit, final LocalDate date,
|
||||
final BigDecimal amount, final GLAccount debit, final GLAccount credit, final String description) {
|
||||
postPair(account.office(), account.getCurrency().getCode(), date, amount, debit, credit, description, deposit.getId(),
|
||||
account.getId());
|
||||
}
|
||||
|
||||
private void postPair(final Office office, final String currencyCode, final LocalDate date, final BigDecimal amount,
|
||||
final GLAccount debit, final GLAccount credit, final String description, final Long savingsTransactionId,
|
||||
final Long savingsAccountId) {
|
||||
final String transactionId = savingsTransactionId == null ? "DDS" + date + amount : "S" + savingsTransactionId;
|
||||
|
||||
for (final JournalEntryType type : List.of(JournalEntryType.DEBIT, JournalEntryType.CREDIT)) {
|
||||
this.journalEntryRepository.saveAndFlush(JournalEntry.createNew(office, null,
|
||||
JournalEntryType.DEBIT.equals(type) ? debit : credit, currencyCode, transactionId, false, date, type, amount,
|
||||
description, PortfolioProductType.SAVING.getValue(), savingsAccountId, null, null, savingsTransactionId, null, null));
|
||||
}
|
||||
}
|
||||
|
||||
/** A ledger account named by a setting, so a bank can point it at one of their own. */
|
||||
private GLAccount configured(final String settingName) {
|
||||
final List<Long> ids = this.jdbcTemplate.queryForList(CONFIGURED_ACCOUNT_SQL, Long.class, settingName); // NOSONAR
|
||||
if (ids.isEmpty() || ids.get(0) == null) {
|
||||
throw new DdsLedgerNotConfiguredException(settingName);
|
||||
}
|
||||
return this.glAccountRepository.findById(ids.get(0)).orElseThrow(() -> new DdsLedgerNotConfiguredException(settingName));
|
||||
}
|
||||
|
||||
private GLAccount controlAccountOf(final SavingsAccount account) {
|
||||
return productAccount(account.productId(), CONTROL_ACCOUNT);
|
||||
}
|
||||
|
||||
/**
|
||||
* The savings product DDS opens customer accounts under. A settlement touches no single customer,
|
||||
* so its ledger accounts and currency come from the product rather than from an account.
|
||||
*/
|
||||
private Long depositProductId() {
|
||||
final List<Long> ids = this.jdbcTemplate.queryForList(CONFIGURED_ACCOUNT_SQL, Long.class, DEPOSIT_PRODUCT); // NOSONAR
|
||||
if (ids.isEmpty() || ids.get(0) == null) {
|
||||
throw new DdsLedgerNotConfiguredException(DEPOSIT_PRODUCT);
|
||||
}
|
||||
return ids.get(0);
|
||||
}
|
||||
|
||||
/** Read off the product rather than assumed, so a bank on another currency is not silently wrong. */
|
||||
private String currencyOf(final Long productId) {
|
||||
final List<String> codes = this.jdbcTemplate.queryForList(PRODUCT_CURRENCY_SQL, String.class, productId); // NOSONAR
|
||||
if (codes.isEmpty()) {
|
||||
throw new DdsLedgerNotConfiguredException("currency for savings product " + productId);
|
||||
}
|
||||
return codes.get(0);
|
||||
}
|
||||
|
||||
/** An account off the savings product's own mapping, so this stays right per product. */
|
||||
private GLAccount productAccount(final Long productId, final int financialAccountType) {
|
||||
final List<Long> ids = this.jdbcTemplate.queryForList(PRODUCT_ACCOUNT_SQL, Long.class, productId, financialAccountType); // NOSONAR
|
||||
final String what = financialAccountType == CONTROL_ACCOUNT ? "control account" : "fund source";
|
||||
if (ids.isEmpty()) {
|
||||
throw new DdsLedgerNotConfiguredException(what + " for savings product " + productId);
|
||||
}
|
||||
return this.glAccountRepository.findById(ids.get(0))
|
||||
.orElseThrow(() -> new DdsLedgerNotConfiguredException(what + " for savings product " + productId));
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounting.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a DDS movement cannot say which ledger accounts it belongs in.
|
||||
*
|
||||
* The caller's whole transaction rolls back rather than the money moving anyway. A balance that
|
||||
* changes with no entry behind it is the harder thing to unpick: nobody finds out until something is
|
||||
* reconciled, and by then there is no record of what should have been written.
|
||||
*/
|
||||
public class DdsLedgerNotConfiguredException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public DdsLedgerNotConfiguredException(final String missing) {
|
||||
super("error.msg.dds.ledger.not.configured",
|
||||
"This cannot be posted because " + missing + " is not set up. Set it before posting DDS transactions.", missing);
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.Collection;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.data.DdsAccountTypeData;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/dds/accounttypes")
|
||||
@Component
|
||||
@Tag(name = "DDS Account Types", description = "A DDS Account Type is a scheme variant a Party account is opened under - group, code, description and auto-interest-posting settings. Master data only: it holds no deposit terms, and interest is accrued off each Party's own rate by the DDS batch job.")
|
||||
@RequiredArgsConstructor
|
||||
public class DdsAccountTypesApiResource {
|
||||
|
||||
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DDSACCOUNTTYPE";
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final DdsAccountTypeReadPlatformService readPlatformService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve DDS Account Types", description = "Returns the list of DDS account types, ordered by code.")
|
||||
public Collection<DdsAccountTypeData> retrieveAll() {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
return readPlatformService.retrieveAll();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{accountTypeId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve a DDS Account Type", description = "Returns the details of a single DDS account type.")
|
||||
public DdsAccountTypeData retrieveOne(
|
||||
@PathParam("accountTypeId") @Parameter(description = "accountTypeId") final Long accountTypeId) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
return readPlatformService.retrieveOne(accountTypeId);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Create a DDS Account Type", description = "Mandatory fields: accountTypeGroup, accountTypeCode, "
|
||||
+ "accountTypeDescription\n" + "\n" + "Optional fields: autoInterestPost, autoInterestPostWhen\n" + "\n"
|
||||
+ "accountTypeCode is stored upper-cased, so it stays a single identity regardless of how it was typed.")
|
||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.createDdsAccountType() //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("{accountTypeId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Update a DDS Account Type", description = "Updates the details of a DDS account type.")
|
||||
public CommandProcessingResult update(
|
||||
@PathParam("accountTypeId") @Parameter(description = "accountTypeId") final Long accountTypeId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.updateDdsAccountType(accountTypeId) //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.domain;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
public interface DdsAccountTypeRepository extends JpaRepository<DdsAccountType, Long> {
|
||||
|
||||
Optional<DdsAccountType> findByAccountTypeCode(String accountTypeCode);
|
||||
|
||||
/**
|
||||
* Schemes that post interest on their own. A posting day is required to act on one, so a scheme
|
||||
* left without a day is not picked up - it would otherwise mean "post monthly, some day".
|
||||
*/
|
||||
@Query("select t from DdsAccountType t where t.active = true and t.autoInterestPost = true "
|
||||
+ "and t.autoInterestPostDay is not null order by t.accountTypeCode")
|
||||
List<DdsAccountType> findDueForAutoInterestPost();
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "DDSACCOUNTTYPE", action = "CREATE")
|
||||
public class CreateDdsAccountTypeCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final DdsAccountTypeWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public CreateDdsAccountTypeCommandHandler(final DdsAccountTypeWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.createDdsAccountType(command);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "DDSACCOUNTTYPE", action = "UPDATE")
|
||||
public class UpdateDdsAccountTypeCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final DdsAccountTypeWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public UpdateDdsAccountTypeCommandHandler(final DdsAccountTypeWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.updateDdsAccountType(command.entityId(), command);
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.serialization;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import java.lang.reflect.Type;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public final class DdsAccountTypeCommandFromApiJsonDeserializer {
|
||||
|
||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("accountTypeGroup", "accountTypeCode",
|
||||
"accountTypeDescription", "autoInterestPost", "autoInterestPostWhen", "autoInterestPostDay", "maturityAutoClose", "active",
|
||||
"prematureLockinMonths", "prematureInterestReduction", "prematureAgentCommissionPenalty",
|
||||
"dateFormat", "locale"));
|
||||
|
||||
/** A rate, not an amount - nothing here may exceed the whole of what it is taken from. */
|
||||
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
|
||||
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
|
||||
public DdsAccountTypeCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
|
||||
this.fromApiJsonHelper = fromApiJsonHelper;
|
||||
}
|
||||
|
||||
public void validateForCreate(final String json) {
|
||||
final JsonElement element = parseAndCheckParameters(json);
|
||||
|
||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("ddsaccounttype");
|
||||
|
||||
v.reset().parameter("accountTypeGroup").value(this.fromApiJsonHelper.extractStringNamed("accountTypeGroup", element)).notBlank()
|
||||
.notExceedingLengthOf(60);
|
||||
v.reset().parameter("accountTypeCode").value(this.fromApiJsonHelper.extractStringNamed("accountTypeCode", element)).notBlank()
|
||||
.notExceedingLengthOf(20);
|
||||
v.reset().parameter("accountTypeDescription")
|
||||
.value(this.fromApiJsonHelper.extractStringNamed("accountTypeDescription", element)).notBlank()
|
||||
.notExceedingLengthOf(200);
|
||||
|
||||
validateOptionalFields(element, v);
|
||||
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
}
|
||||
|
||||
public void validateForUpdate(final String json) {
|
||||
final JsonElement element = parseAndCheckParameters(json);
|
||||
|
||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("ddsaccounttype");
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists("accountTypeGroup", element)) {
|
||||
v.reset().parameter("accountTypeGroup").value(this.fromApiJsonHelper.extractStringNamed("accountTypeGroup", element))
|
||||
.notBlank().notExceedingLengthOf(60);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("accountTypeCode", element)) {
|
||||
v.reset().parameter("accountTypeCode").value(this.fromApiJsonHelper.extractStringNamed("accountTypeCode", element)).notBlank()
|
||||
.notExceedingLengthOf(20);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("accountTypeDescription", element)) {
|
||||
v.reset().parameter("accountTypeDescription")
|
||||
.value(this.fromApiJsonHelper.extractStringNamed("accountTypeDescription", element)).notBlank()
|
||||
.notExceedingLengthOf(200);
|
||||
}
|
||||
|
||||
validateOptionalFields(element, v);
|
||||
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
}
|
||||
|
||||
private JsonElement parseAndCheckParameters(final String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
throw new InvalidJsonException();
|
||||
}
|
||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
|
||||
|
||||
}.getType();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
|
||||
return this.fromApiJsonHelper.parse(json);
|
||||
}
|
||||
|
||||
private void validateOptionalFields(final JsonElement element, final DataValidatorBuilder v) {
|
||||
if (this.fromApiJsonHelper.parameterExists("autoInterestPostWhen", element)) {
|
||||
v.reset().parameter("autoInterestPostWhen")
|
||||
.value(this.fromApiJsonHelper.extractStringNamed("autoInterestPostWhen", element)).ignoreIfNull()
|
||||
.notExceedingLengthOf(20);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("autoInterestPostDay", element)) {
|
||||
v.reset().parameter("autoInterestPostDay")
|
||||
.value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("autoInterestPostDay", element)).ignoreIfNull()
|
||||
.inMinMaxRange(1, 31);
|
||||
}
|
||||
|
||||
// Premature closure terms. Zero is allowed throughout - a scheme may close early from day
|
||||
// one, or charge the customer or the agent nothing - so these are floored at zero, not at one.
|
||||
if (this.fromApiJsonHelper.parameterExists("prematureLockinMonths", element)) {
|
||||
v.reset().parameter("prematureLockinMonths")
|
||||
.value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("prematureLockinMonths", element)).ignoreIfNull()
|
||||
.inMinMaxRange(0, 600);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("prematureInterestReduction", element)) {
|
||||
v.reset().parameter("prematureInterestReduction")
|
||||
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("prematureInterestReduction", element))
|
||||
.ignoreIfNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("prematureAgentCommissionPenalty", element)) {
|
||||
v.reset().parameter("prematureAgentCommissionPenalty")
|
||||
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("prematureAgentCommissionPenalty", element))
|
||||
.ignoreIfNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
|
||||
}
|
||||
}
|
||||
|
||||
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
|
||||
if (!dataValidationErrors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
|
||||
dataValidationErrors);
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.service;
|
||||
|
||||
import java.util.Collection;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.data.DdsAccountTypeData;
|
||||
|
||||
public interface DdsAccountTypeReadPlatformService {
|
||||
|
||||
Collection<DdsAccountTypeData> retrieveAll();
|
||||
|
||||
DdsAccountTypeData retrieveOne(Long accountTypeId);
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.service;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.data.DdsAccountTypeData;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.exception.DdsAccountTypeNotFoundException;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeReadPlatformService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
private static final DdsAccountTypeMapper MAPPER = new DdsAccountTypeMapper();
|
||||
|
||||
private static final class DdsAccountTypeMapper implements RowMapper<DdsAccountTypeData> {
|
||||
|
||||
public String schema() {
|
||||
return " t.id as id, t.account_type_group as accountTypeGroup, t.account_type_code as accountTypeCode, "
|
||||
+ " t.account_type_description as accountTypeDescription, t.auto_interest_post as autoInterestPost, "
|
||||
+ " t.auto_interest_post_when as autoInterestPostWhen, t.auto_interest_post_day as autoInterestPostDay, "
|
||||
+ " t.maturity_auto_close as maturityAutoClose, "
|
||||
+ " t.premature_lockin_months as prematureLockinMonths, "
|
||||
+ " t.premature_interest_reduction as prematureInterestReduction, "
|
||||
+ " t.premature_agent_commission_penalty as prematureAgentCommissionPenalty, "
|
||||
+ " t.is_active as active, "
|
||||
+ " t.created_date as createdDate, t.lastmodified_date as lastModifiedDate "
|
||||
+ " from m_dds_account_type t";
|
||||
}
|
||||
|
||||
@Override
|
||||
public DdsAccountTypeData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||
return DdsAccountTypeData.builder() //
|
||||
.id(rs.getLong("id")) //
|
||||
.accountTypeGroup(rs.getString("accountTypeGroup")) //
|
||||
.accountTypeCode(rs.getString("accountTypeCode")) //
|
||||
.accountTypeDescription(rs.getString("accountTypeDescription")) //
|
||||
.autoInterestPost(rs.getBoolean("autoInterestPost")) //
|
||||
.autoInterestPostWhen(rs.getString("autoInterestPostWhen")) //
|
||||
.autoInterestPostDay(JdbcSupport.getInteger(rs, "autoInterestPostDay")) //
|
||||
.maturityAutoClose(rs.getBoolean("maturityAutoClose")) //
|
||||
.prematureLockinMonths(JdbcSupport.getInteger(rs, "prematureLockinMonths")) //
|
||||
.prematureInterestReduction(rs.getBigDecimal("prematureInterestReduction")) //
|
||||
.prematureAgentCommissionPenalty(rs.getBigDecimal("prematureAgentCommissionPenalty")) //
|
||||
.active(rs.getBoolean("active")) //
|
||||
.createdDate(localDateTime(rs, "createdDate")) //
|
||||
.lastModifiedDate(localDateTime(rs, "lastModifiedDate")) //
|
||||
.build();
|
||||
}
|
||||
|
||||
/** JdbcSupport has no LocalDateTime helper, and these audit columns are nullable. */
|
||||
private LocalDateTime localDateTime(final ResultSet rs, final String columnName) throws SQLException {
|
||||
final Timestamp timestamp = rs.getTimestamp(columnName);
|
||||
return timestamp == null ? null : timestamp.toLocalDateTime();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<DdsAccountTypeData> retrieveAll() {
|
||||
final String sql = "select " + MAPPER.schema() + " order by t.account_type_code";
|
||||
return this.jdbcTemplate.query(sql, MAPPER); // NOSONAR
|
||||
}
|
||||
|
||||
@Override
|
||||
public DdsAccountTypeData retrieveOne(final Long accountTypeId) {
|
||||
try {
|
||||
final String sql = "select " + MAPPER.schema() + " where t.id = ?";
|
||||
return this.jdbcTemplate.queryForObject(sql, MAPPER, accountTypeId); // NOSONAR
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
throw new DdsAccountTypeNotFoundException(accountTypeId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.service;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
|
||||
public interface DdsAccountTypeWritePlatformService {
|
||||
|
||||
CommandProcessingResult createDdsAccountType(JsonCommand command);
|
||||
|
||||
CommandProcessingResult updateDdsAccountType(Long accountTypeId, JsonCommand command);
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.service;
|
||||
|
||||
import jakarta.persistence.PersistenceException;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountType;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountTypeRepository;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.exception.DdsAccountTypeNotFoundException;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.serialization.DdsAccountTypeCommandFromApiJsonDeserializer;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.orm.jpa.JpaSystemException;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsAccountTypeWritePlatformService {
|
||||
|
||||
private final DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer;
|
||||
private final DdsAccountTypeRepository ddsAccountTypeRepository;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult createDdsAccountType(final JsonCommand command) {
|
||||
|
||||
try {
|
||||
this.fromApiJsonDeserializer.validateForCreate(command.json());
|
||||
|
||||
final DdsAccountType accountType = DdsAccountType.fromJson(command);
|
||||
this.ddsAccountTypeRepository.saveAndFlush(accountType);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(accountType.getId()) //
|
||||
.build();
|
||||
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||
handleDataIntegrityIssues(dve.getMostSpecificCause(), dve);
|
||||
return CommandProcessingResult.empty();
|
||||
} catch (final PersistenceException dve) {
|
||||
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
|
||||
handleDataIntegrityIssues(throwable, dve);
|
||||
return CommandProcessingResult.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult updateDdsAccountType(final Long accountTypeId, final JsonCommand command) {
|
||||
|
||||
try {
|
||||
this.fromApiJsonDeserializer.validateForUpdate(command.json());
|
||||
|
||||
final DdsAccountType accountType = this.ddsAccountTypeRepository.findById(accountTypeId)
|
||||
.orElseThrow(() -> new DdsAccountTypeNotFoundException(accountTypeId));
|
||||
final Map<String, Object> changesOnly = accountType.update(command);
|
||||
|
||||
if (!changesOnly.isEmpty()) {
|
||||
this.ddsAccountTypeRepository.saveAndFlush(accountType);
|
||||
}
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(accountTypeId) //
|
||||
.with(changesOnly) //
|
||||
.build();
|
||||
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||
handleDataIntegrityIssues(dve.getMostSpecificCause(), dve);
|
||||
return CommandProcessingResult.empty();
|
||||
} catch (final PersistenceException dve) {
|
||||
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
|
||||
handleDataIntegrityIssues(throwable, dve);
|
||||
return CommandProcessingResult.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleDataIntegrityIssues(final Throwable realCause, final Exception dve) {
|
||||
if (realCause.getMessage() != null && realCause.getMessage().contains("account_type_code")) {
|
||||
throw new PlatformDataIntegrityException("error.msg.dds.account.type.duplicate.code",
|
||||
"A DDS account type with this code already exists");
|
||||
}
|
||||
|
||||
log.error("Error occured.", dve);
|
||||
throw ErrorHandler.getMappable(dve, "error.msg.dds.account.type.unknown.data.integrity.issue",
|
||||
"Unknown data integrity issue with resource: " + realCause.getMessage());
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.starter;
|
||||
|
||||
import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountTypeRepository;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.serialization.DdsAccountTypeCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeReadPlatformService;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeWritePlatformService;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeWritePlatformServiceJpaRepositoryImpl;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
@Configuration
|
||||
public class DdsAccountTypeConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(DdsAccountTypeReadPlatformService.class)
|
||||
public DdsAccountTypeReadPlatformService ddsAccountTypeReadPlatformService(JdbcTemplate jdbcTemplate) {
|
||||
return new DdsAccountTypeReadPlatformServiceImpl(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(DdsAccountTypeWritePlatformService.class)
|
||||
public DdsAccountTypeWritePlatformService ddsAccountTypeWritePlatformService(
|
||||
DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer, DdsAccountTypeRepository ddsAccountTypeRepository) {
|
||||
return new DdsAccountTypeWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, ddsAccountTypeRepository);
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -73,9 +73,13 @@ public class BatchRunsApiResource {
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Generate a DDS Batch Run", description = "Mandatory fields: jobType (INTEREST_CALCULATION, "
|
||||
+ "OD_INTEREST_CALCULATION or COMMISSION_CALCULATION), branchScope (All or Selected), jobDate, agentFrom, agentTo\n"
|
||||
+ "\n" + "Optional fields: partyFrom, partyTo (interest jobs only)")
|
||||
@Operation(summary = "Generate a DDS Batch Run", description = "Mandatory fields: jobType, branchScope (All or Selected), "
|
||||
+ "jobDate, agentFrom, agentTo\n" + "\n"
|
||||
+ "jobType is one of four Calculate/Post pairs. A calculation job works out what has been earned since the last one "
|
||||
+ "and parks it (interest in the Party's accrued-interest figure, commission in the Agent's owed figure); the "
|
||||
+ "matching posting job moves that parked figure into the customer's balance or out to the agent: "
|
||||
+ "INTEREST_CALCULATION / INTEREST_POSTING, OD_INTEREST_CALCULATION / OD_INTEREST_POSTING, "
|
||||
+ "COMMISSION_CALCULATION / COMMISSION_POSTING\n" + "\n" + "Optional fields: partyFrom, partyTo (interest jobs only)")
|
||||
public CommandProcessingResult generate(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.generateDdsBatchRun() //
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.batch.jobs.postddsinterest;
|
||||
|
||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
/** Wires the Post DDS Interest job into the platform scheduler. */
|
||||
@Configuration
|
||||
public class PostDdsInterestConfig {
|
||||
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
|
||||
@Autowired
|
||||
private BatchRunWritePlatformService batchRunWritePlatformService;
|
||||
|
||||
@Bean
|
||||
public PostDdsInterestTasklet postDdsInterestTasklet() {
|
||||
return new PostDdsInterestTasklet(batchRunWritePlatformService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
protected Step postDdsInterestStep() {
|
||||
return new StepBuilder(JobName.POST_DDS_INTEREST.name(), jobRepository)
|
||||
.tasklet(postDdsInterestTasklet(), transactionManager).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job postDdsInterestJob() {
|
||||
return new JobBuilder(JobName.POST_DDS_INTEREST.name(), jobRepository).start(postDdsInterestStep())
|
||||
.incrementer(new RunIdIncrementer()).build();
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.batch.jobs.postddsinterest;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
|
||||
/**
|
||||
* Posts DDS interest each night: the schemes whose day has come, and every account that has matured.
|
||||
*
|
||||
* The Account Type screen lets a scheme say "post interest automatically, monthly, on this day", and
|
||||
* the first pass acts on that. On the great majority of days no scheme is due and it does nothing.
|
||||
*
|
||||
* The second pass is there because that first one is not enough on its own. A scheme's posting is
|
||||
* monthly and can be switched off entirely, so a term ending between two runs leaves its last stretch
|
||||
* uncredited - and a payout made in the meantime hands the customer less than they earned. Maturity
|
||||
* is the one date that cannot wait for the next run, so accounts reaching it are brought up to date
|
||||
* here, whatever their scheme says. By morning the account is whole, and the figure the clerk reads
|
||||
* on the closure list is the figure that will be paid.
|
||||
*
|
||||
* It works off the platform's business date rather than the wall clock, so a posting always belongs to
|
||||
* the day the bank is currently working on.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class PostDdsInterestTasklet implements Tasklet {
|
||||
|
||||
private final BatchRunWritePlatformService batchRunWritePlatformService;
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) {
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
|
||||
final int schemesPosted = this.batchRunWritePlatformService.postDueAutoInterest(businessDate);
|
||||
|
||||
if (schemesPosted == 0) {
|
||||
log.debug("Post DDS Interest: no scheme is due on {}", businessDate);
|
||||
} else {
|
||||
log.info("Post DDS Interest: posted interest for {} scheme(s) as on {}", schemesPosted, businessDate);
|
||||
}
|
||||
|
||||
final int maturedPosted = this.batchRunWritePlatformService.postMaturityInterest(businessDate);
|
||||
|
||||
if (maturedPosted == 0) {
|
||||
log.debug("Post DDS Interest: no matured account is short of interest on {}", businessDate);
|
||||
} else {
|
||||
log.info("Post DDS Interest: brought {} matured account(s) up to their maturity date as on {}", maturedPosted, businessDate);
|
||||
}
|
||||
|
||||
final int promoted = this.batchRunWritePlatformService.promoteMinorsWhoHaveComeOfAge(businessDate);
|
||||
|
||||
if (promoted == 0) {
|
||||
log.debug("Post DDS Interest: no minor's account came of age with KYC verified on {}", businessDate);
|
||||
} else {
|
||||
log.info("Post DDS Interest: turned {} minor's account(s) over to their holder as on {}", promoted, businessDate);
|
||||
}
|
||||
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
}
|
||||
+19
-7
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.dds.batch.serialization;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import java.lang.reflect.Type;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
@@ -33,19 +34,26 @@ import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public final class BatchRunCommandFromApiJsonDeserializer {
|
||||
|
||||
public static final Set<String> SUPPORTED_JOB_TYPES = new HashSet<>(
|
||||
Arrays.asList("INTEREST_CALCULATION", "OD_INTEREST_CALCULATION", "COMMISSION_CALCULATION"));
|
||||
// The four Calculate/Post pairs from the legacy Interest Part menu. A calculation job works out
|
||||
// what has been earned and parks it; the matching posting job moves it where it belongs.
|
||||
//
|
||||
// Lists, not Sets, so isOneOfTheseStringValues resolves to the overload that lowercases both
|
||||
// sides - the varargs one lowercases only the submitted value and so never matches these.
|
||||
public static final List<String> SUPPORTED_JOB_TYPES = List.of("INTEREST_CALCULATION", "INTEREST_POSTING",
|
||||
"OD_INTEREST_CALCULATION", "OD_INTEREST_POSTING", "COMMISSION_CALCULATION", "COMMISSION_POSTING",
|
||||
"MATURITY_AUTO_CLOSE");
|
||||
|
||||
private static final Set<String> SUPPORTED_BRANCH_SCOPES = new HashSet<>(Arrays.asList("All", "Selected"));
|
||||
private static final List<String> SUPPORTED_BRANCH_SCOPES = List.of("All", "Selected");
|
||||
|
||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("jobType", "branchScope", "officeNames",
|
||||
"jobDate", "agentFrom", "agentTo", "partyFrom", "partyTo", "dateFormat", "locale"));
|
||||
"jobDate", "agentFrom", "agentTo", "partyFrom", "partyTo", "subType", "dateFormat", "locale"));
|
||||
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
|
||||
@@ -70,16 +78,20 @@ public final class BatchRunCommandFromApiJsonDeserializer {
|
||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
||||
|
||||
v.reset().parameter("jobType").value(this.fromApiJsonHelper.extractStringNamed("jobType", element)).notBlank()
|
||||
.isOneOfTheseStringValues(SUPPORTED_JOB_TYPES.toArray());
|
||||
.isOneOfTheseStringValues(SUPPORTED_JOB_TYPES);
|
||||
final String branchScope = this.fromApiJsonHelper.extractStringNamed("branchScope", element);
|
||||
v.reset().parameter("branchScope").value(branchScope).notBlank().isOneOfTheseStringValues(SUPPORTED_BRANCH_SCOPES.toArray());
|
||||
v.reset().parameter("branchScope").value(branchScope).notBlank().isOneOfTheseStringValues(SUPPORTED_BRANCH_SCOPES);
|
||||
|
||||
if ("Selected".equals(branchScope)) {
|
||||
v.reset().parameter("officeNames").value(this.fromApiJsonHelper.extractJsonArrayNamed("officeNames", element))
|
||||
.jsonArrayNotEmpty();
|
||||
}
|
||||
|
||||
v.reset().parameter("jobDate").value(this.fromApiJsonHelper.extractLocalDateNamed("jobDate", element)).notNull();
|
||||
// Not in the future. A run works out what has been earned up to its job date, so a date that
|
||||
// has not arrived pays interest for days nobody has held their money yet - and worse, the run
|
||||
// stamps itself as calculated through that date, so the real days are skipped when they come.
|
||||
final LocalDate jobDate = this.fromApiJsonHelper.extractLocalDateNamed("jobDate", element);
|
||||
v.reset().parameter("jobDate").value(jobDate).notNull().validateDateBeforeOrEqual(DateUtils.getBusinessLocalDate());
|
||||
v.reset().parameter("agentFrom").value(this.fromApiJsonHelper.extractLongNamed("agentFrom", element)).notNull()
|
||||
.integerGreaterThanZero();
|
||||
v.reset().parameter("agentTo").value(this.fromApiJsonHelper.extractLongNamed("agentTo", element)).notNull()
|
||||
|
||||
+18
-3
@@ -45,9 +45,10 @@ public class BatchRunReadPlatformServiceImpl implements BatchRunReadPlatformServ
|
||||
private static final class BatchRunMapper implements RowMapper<BatchRunData> {
|
||||
|
||||
public String schema() {
|
||||
return " b.id as id, b.job_type as jobType, b.branch_scope as branchScope, b.job_date as jobDate, "
|
||||
return " b.id as id, b.job_type as jobType, b.branch_scope as branchScope, b.sub_type as subType, b.job_date as jobDate, "
|
||||
+ " b.agent_from as agentFrom, b.agent_to as agentTo, b.party_from as partyFrom, "
|
||||
+ " b.party_to as partyTo, b.status as status, b.total_rows as totalRows, b.total_amount as totalAmount, "
|
||||
+ " b.party_to as partyTo, b.status as status, b.total_rows as totalRows, "
|
||||
+ " b.rows_posted as rowsPosted, b.failure_reason as failureReason, b.total_amount as totalAmount, "
|
||||
+ " b.generated_on_date as generatedOnDate, b.posted_on_date as postedOnDate from m_dds_batch_run b";
|
||||
}
|
||||
|
||||
@@ -57,6 +58,7 @@ public class BatchRunReadPlatformServiceImpl implements BatchRunReadPlatformServ
|
||||
.id(rs.getLong("id")) //
|
||||
.jobType(rs.getString("jobType")) //
|
||||
.branchScope(rs.getString("branchScope")) //
|
||||
.subType(rs.getString("subType")) //
|
||||
.jobDate(JdbcSupport.getLocalDate(rs, "jobDate")) //
|
||||
.agentFrom(rs.getLong("agentFrom")) //
|
||||
.agentTo(rs.getLong("agentTo")) //
|
||||
@@ -64,6 +66,8 @@ public class BatchRunReadPlatformServiceImpl implements BatchRunReadPlatformServ
|
||||
.partyTo(JdbcSupport.getLong(rs, "partyTo")) //
|
||||
.status(rs.getString("status")) //
|
||||
.totalRows(rs.getInt("totalRows")) //
|
||||
.rowsPosted(rs.getInt("rowsPosted")) //
|
||||
.failureReason(rs.getString("failureReason")) //
|
||||
.totalAmount(rs.getBigDecimal("totalAmount")) //
|
||||
.generatedOnDate(JdbcSupport.getLocalDate(rs, "generatedOnDate")) //
|
||||
.postedOnDate(JdbcSupport.getLocalDate(rs, "postedOnDate")) //
|
||||
@@ -77,7 +81,10 @@ public class BatchRunReadPlatformServiceImpl implements BatchRunReadPlatformServ
|
||||
public String schema() {
|
||||
return " r.batch_run_id as batchRunId, r.id as id, r.party_id as partyId, p.party_code as partyCode, "
|
||||
+ " p.party_name as partyName, r.agent_id as agentId, a.agent_code as agentCode, "
|
||||
+ " r.amount as amount from m_dds_batch_run_row r " + " left join m_dds_party p on p.id = r.party_id "
|
||||
+ " a.agent_name as agentName, r.amount as amount, r.base_amount as baseAmount, "
|
||||
+ " r.rate_percent as ratePercent, r.gross_amount as grossAmount, r.tds_amount as tdsAmount, "
|
||||
+ " r.deduction_amount as deductionAmount, r.from_date as fromDate, r.to_date as toDate "
|
||||
+ " from m_dds_batch_run_row r " + " left join m_dds_party p on p.id = r.party_id "
|
||||
+ " join m_dds_agent a on a.id = r.agent_id";
|
||||
}
|
||||
|
||||
@@ -91,7 +98,15 @@ public class BatchRunReadPlatformServiceImpl implements BatchRunReadPlatformServ
|
||||
.partyName(rs.getString("partyName")) //
|
||||
.agentId(rs.getLong("agentId")) //
|
||||
.agentCode(rs.getString("agentCode")) //
|
||||
.agentName(rs.getString("agentName")) //
|
||||
.amount(rs.getBigDecimal("amount")) //
|
||||
.baseAmount(rs.getBigDecimal("baseAmount")) //
|
||||
.ratePercent(rs.getBigDecimal("ratePercent")) //
|
||||
.grossAmount(rs.getBigDecimal("grossAmount")) //
|
||||
.tdsAmount(rs.getBigDecimal("tdsAmount")) //
|
||||
.deductionAmount(rs.getBigDecimal("deductionAmount")) //
|
||||
.fromDate(JdbcSupport.getLocalDate(rs, "fromDate")) //
|
||||
.toDate(JdbcSupport.getLocalDate(rs, "toDate")) //
|
||||
.build();
|
||||
return Map.entry(batchRunId, data);
|
||||
}
|
||||
|
||||
+52
@@ -18,12 +18,64 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.batch.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||
|
||||
public interface BatchRunWritePlatformService {
|
||||
|
||||
CommandProcessingResult generateBatchRun(JsonCommand command);
|
||||
|
||||
CommandProcessingResult postBatchRun(Long batchRunId, JsonCommand command);
|
||||
|
||||
/**
|
||||
* Posts interest for every scheme whose Account Type says to do it automatically and whose posting
|
||||
* day falls on the given date. Driven by the Post DDS Interest scheduled job.
|
||||
*
|
||||
* @return how many schemes were posted for - zero on the great majority of days, when nothing is
|
||||
* due.
|
||||
*/
|
||||
int postDueAutoInterest(LocalDate businessDate);
|
||||
|
||||
/**
|
||||
* Brings every matured account's interest up to its own maturity date.
|
||||
*
|
||||
* Run nightly beside the scheme postings. Interest otherwise arrives only when a posting run
|
||||
* happens - monthly, per scheme, and switchable off - so a term ending between two runs leaves
|
||||
* its last stretch uncredited, and a payout in the meantime pays the customer short.
|
||||
*
|
||||
* @return how many accounts were credited; zero on most nights.
|
||||
*/
|
||||
int postMaturityInterest(LocalDate businessDate);
|
||||
|
||||
/**
|
||||
* Works out one account's interest up to a date and credits it, returning what was paid.
|
||||
*
|
||||
* Called by the nightly pass above, and again by the maturity payout as a second line of defence
|
||||
* in case that pass never ran. Returns zero, having written nothing, when interest is already
|
||||
* current.
|
||||
*/
|
||||
BigDecimal postInterestThrough(Party party, LocalDate through);
|
||||
|
||||
/**
|
||||
* Turns over the minors' accounts whose holders have come of age and had their own KYC verified.
|
||||
*
|
||||
* Run nightly. The birthday alone does not do it: an eighteen-year-old the bank has not yet
|
||||
* identified is somebody it cannot pay, so the turnover waits on their KYC - and until then the
|
||||
* payout refuses while collections carry on as before.
|
||||
*
|
||||
* @return how many accounts were turned over; zero on almost every night.
|
||||
*/
|
||||
int promoteMinorsWhoHaveComeOfAge(LocalDate businessDate);
|
||||
|
||||
/**
|
||||
* Credits every row on a run and marks it posted. Called from the background thread that
|
||||
* {@code postBatchRun} hands the work to, not from a request.
|
||||
*/
|
||||
void applyAndMarkPosted(Long batchRunId);
|
||||
|
||||
/** Records on the run why it stopped, when it did, so a screen can say so rather than only a log. */
|
||||
void markPostingFailed(Long batchRunId, String reason);
|
||||
}
|
||||
|
||||
+793
-66
@@ -20,39 +20,80 @@ package org.apache.fineract.portfolio.dds.batch.service;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
||||
import org.apache.fineract.organisation.agent.domain.AgentRepository;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentCommissionAccountUnusableException;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
|
||||
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlab;
|
||||
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
|
||||
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
|
||||
import org.apache.fineract.portfolio.dds.calculation.DdsCommissionCalculation;
|
||||
import org.apache.fineract.portfolio.dds.calculation.DdsInterestCalculation;
|
||||
import org.apache.fineract.portfolio.dds.batch.domain.BatchRun;
|
||||
import org.apache.fineract.portfolio.dds.batch.domain.BatchRunRepository;
|
||||
import org.apache.fineract.portfolio.dds.batch.domain.BatchRunRow;
|
||||
import org.apache.fineract.portfolio.dds.batch.exception.BatchRunAlreadyPostedException;
|
||||
import org.apache.fineract.portfolio.dds.batch.exception.BatchRunNotFoundException;
|
||||
import org.apache.fineract.portfolio.dds.batch.exception.NoCommissionSlabException;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountType;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountTypeRepository;
|
||||
import org.apache.fineract.portfolio.dds.batch.serialization.BatchRunCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
|
||||
import org.apache.fineract.portfolio.dds.party.exception.PartyNotFoundException;
|
||||
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
|
||||
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransaction;
|
||||
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* The DDS Interest Part: the four Calculate/Post job pairs from the legacy menu.
|
||||
*
|
||||
* A <em>calculation</em> job works out what has been earned since the last one and parks it - interest
|
||||
* in the Party's "Int.Calc" figure, commission in the Agent's "Comm" figure. Neither is spendable yet.
|
||||
* A <em>posting</em> job then moves that parked figure where it belongs: into the customer's balance,
|
||||
* or out to the agent. This is why the legacy menu has eight entries rather than four.
|
||||
*
|
||||
* Every job is itself a two-step Generate/Post: Generate saves the rows it worked out and changes
|
||||
* nothing else, Post applies exactly those rows. A figure that looks wrong is thrown away by simply
|
||||
* not posting it.
|
||||
*
|
||||
* Nothing here can be applied twice. A calculation stamps how far it has calculated through, so
|
||||
* re-running it for the same period finds no days left; a posting takes its amount off the parked
|
||||
* figure, so re-posting finds nothing left to move.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWritePlatformService {
|
||||
|
||||
private static final BigDecimal DAYS_IN_YEAR = BigDecimal.valueOf(365);
|
||||
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
|
||||
private static final String INTEREST_CALCULATION = "INTEREST_CALCULATION";
|
||||
private static final String INTEREST_POSTING = "INTEREST_POSTING";
|
||||
private static final String OD_INTEREST_CALCULATION = "OD_INTEREST_CALCULATION";
|
||||
private static final String OD_INTEREST_POSTING = "OD_INTEREST_POSTING";
|
||||
private static final String COMMISSION_CALCULATION = "COMMISSION_CALCULATION";
|
||||
private static final String COMMISSION_POSTING = "COMMISSION_POSTING";
|
||||
private static final String MATURITY_AUTO_CLOSE = "MATURITY_AUTO_CLOSE";
|
||||
|
||||
private static final String ACCOUNT_SLAB = "ACCOUNT";
|
||||
|
||||
private static final String DIRECTION_CREDIT = "CREDIT";
|
||||
private static final String DIRECTION_DEBIT = "DEBIT";
|
||||
|
||||
private final BatchRunCommandFromApiJsonDeserializer fromApiJsonDeserializer;
|
||||
private final BatchRunRepository batchRunRepository;
|
||||
@@ -60,6 +101,40 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
private final AgentRepository agentRepository;
|
||||
private final PartyTransactionRepository partyTransactionRepository;
|
||||
private final CommissionSlabRepository commissionSlabRepository;
|
||||
private final DdsAccountTypeRepository ddsAccountTypeRepository;
|
||||
|
||||
/** Puts a commission payment onto the bank's own books as well as DDS's - see DdsLedger. */
|
||||
private final DdsLedger ddsLedger;
|
||||
/** Runs the crediting away from the request that asked for it - see postBatchRun. */
|
||||
private final DdsBatchPostingRunner postingRunner;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* Settles a matured account, for a Maturity Auto Close run.
|
||||
*
|
||||
* Held as a provider rather than the service itself because the two point at each other - paying
|
||||
* a maturity brings its interest up to date through this class, and this class hands the settling
|
||||
* back to that one. Asked for at the moment it is used, the pair wire up; asked for at
|
||||
* construction, neither can be built. Reused rather than reimplemented here so an automatic
|
||||
* settlement and a clerk's go through the same code and cannot drift apart.
|
||||
*/
|
||||
private final ObjectProvider<PartyWritePlatformService> partyWritePlatformService;
|
||||
|
||||
/**
|
||||
* Everything needed to say whether an agent's commission can be paid into an account, and if not,
|
||||
* why. Read in one go so each reason can be named rather than the deposit simply being attempted.
|
||||
*/
|
||||
private static final String COMMISSION_ACCOUNT_SQL = "select a.id, a.status_enum, a.product_id, p.deposit_type_enum "
|
||||
+ "from m_savings_account a join m_savings_product p on p.id = a.product_id where a.account_no = ?";
|
||||
|
||||
/** The product DDS opens customers' scheme accounts under - see 0492. */
|
||||
private static final String DDS_DEPOSIT_PRODUCT_SQL = "select value from c_configuration "
|
||||
+ "where name = 'dds-deposit-product' and enabled = true";
|
||||
|
||||
private static final int SAVINGS_DEPOSIT_TYPE = 100;
|
||||
private static final int FIXED_DEPOSIT_TYPE = 200;
|
||||
private static final int ACTIVE_SAVINGS_STATUS = 300;
|
||||
private static final int DORMANT_SAVINGS_STATUS = 303;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -77,14 +152,26 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
|
||||
final Set<String> officeNames = "Selected".equals(branchScope) ? extractOfficeNames(command) : null;
|
||||
|
||||
final BatchRun batchRun = new BatchRun(jobType, branchScope, jobDate, agentFrom, agentTo, partyFrom, partyTo, LocalDate.now());
|
||||
// Absent means every scheme, which is what a run started by hand from the screen does. The
|
||||
// scheduled job names one, because a posting day belongs to an account type rather than the bank.
|
||||
final String subType = command.parameterExists("subType") ? command.stringValueOfParameterNamed("subType") : null;
|
||||
|
||||
final BatchRun batchRun = new BatchRun(jobType, branchScope, jobDate, agentFrom, agentTo, partyFrom, partyTo, LocalDate.now(),
|
||||
subType);
|
||||
|
||||
switch (jobType) {
|
||||
case "INTEREST_CALCULATION" -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, false,
|
||||
officeNames);
|
||||
case "OD_INTEREST_CALCULATION" -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, true,
|
||||
officeNames);
|
||||
case "COMMISSION_CALCULATION" -> generateCommissionRows(batchRun, agentFrom, agentTo, jobDate, officeNames);
|
||||
case INTEREST_CALCULATION -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, false,
|
||||
officeNames, subType);
|
||||
case OD_INTEREST_CALCULATION -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, true,
|
||||
officeNames, subType);
|
||||
case INTEREST_POSTING -> generateInterestPostingRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, false,
|
||||
officeNames, subType);
|
||||
case OD_INTEREST_POSTING -> generateInterestPostingRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, true,
|
||||
officeNames, subType);
|
||||
case COMMISSION_CALCULATION -> generateCommissionRows(batchRun, agentFrom, agentTo, jobDate, officeNames);
|
||||
case COMMISSION_POSTING -> generateCommissionPostingRows(batchRun, agentFrom, agentTo, jobDate, officeNames);
|
||||
case MATURITY_AUTO_CLOSE -> generateMaturityAutoCloseRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate,
|
||||
officeNames, subType);
|
||||
default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType);
|
||||
}
|
||||
|
||||
@@ -96,9 +183,11 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Parses the "Branch" checklist selection - null/empty means no filter ("All"). Matched
|
||||
* against Agent.homeBranch by name, since that field is plain text (not a real Office FK)
|
||||
* mirroring the same free-text branch concept the legacy screen itself used. */
|
||||
/**
|
||||
* Parses the "Branch" checklist selection - null/empty means no filter ("All"). Matched against
|
||||
* Agent.homeBranch by name, since that field is plain text (not a real Office FK) mirroring the
|
||||
* same free-text branch concept the legacy screen itself used.
|
||||
*/
|
||||
private Set<String> extractOfficeNames(final JsonCommand command) {
|
||||
if (!command.parameterExists("officeNames")) {
|
||||
return null;
|
||||
@@ -118,47 +207,211 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
return homeBranch != null && officeNames.contains(homeBranch.trim().toUpperCase());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Interest calculation
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Works out each account's interest since it was last calculated, up to the job date.
|
||||
*
|
||||
* A daily-deposit balance is built up a little at a time, so the closing balance says nothing
|
||||
* about how long the money was actually held: an instalment paid on the last day of the month has
|
||||
* earned a day, not a month. Each account's interest is therefore worked out from its daily
|
||||
* product - its balance summed over every day it stood at that balance - which is the same basis
|
||||
* the maturity figure quoted at account opening uses, so the two agree.
|
||||
*/
|
||||
private void generateInterestRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo, final Long partyFrom,
|
||||
final Long partyTo, final LocalDate jobDate, final boolean odOnly, final Set<String> officeNames) {
|
||||
final List<Party> parties = odOnly
|
||||
? this.partyRepository.findForOdInterestBatch(agentFrom, agentTo, partyFrom, partyTo)
|
||||
: this.partyRepository.findForInterestBatch(agentFrom, agentTo, partyFrom, partyTo);
|
||||
final Long partyTo, final LocalDate jobDate, final boolean odInterest, final Set<String> officeNames,
|
||||
final String subType) {
|
||||
|
||||
final List<Party> parties = odInterest
|
||||
? this.partyRepository.findForOdInterestBatch(agentFrom, agentTo, partyFrom, partyTo, subType)
|
||||
: this.partyRepository.findForInterestBatch(agentFrom, agentTo, partyFrom, partyTo, subType);
|
||||
|
||||
final List<Party> eligible = new ArrayList<>(parties.size());
|
||||
for (final Party party : parties) {
|
||||
if (matchesSelectedBranch(party.getAgent().getHomeBranch(), officeNames)) {
|
||||
eligible.add(party);
|
||||
}
|
||||
}
|
||||
if (eligible.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Map<Long, List<PartyTransaction>> movements = movementsByParty(eligible, jobDate);
|
||||
|
||||
for (final Party party : eligible) {
|
||||
final BigDecimal rate = party.getInterestRate();
|
||||
if (rate == null || rate.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Where this run picks up: the last run's through-date, or the day the account opened if
|
||||
// it has never been calculated.
|
||||
final LocalDate accrueFrom = party.interestCalculatedThrough(odInterest) != null
|
||||
? party.interestCalculatedThrough(odInterest)
|
||||
: party.getOpeningDate();
|
||||
if (!accrueFrom.isBefore(jobDate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final BigDecimal dailyProduct = DdsInterestCalculation.dailyProduct(party.getOpeningBalance(),
|
||||
movementsOf(movements.getOrDefault(party.getId(), List.of())), accrueFrom, jobDate);
|
||||
if (dailyProduct.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final BigDecimal interest = DdsInterestCalculation.interestOn(dailyProduct, rate);
|
||||
if (interest.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
batchRun.addRow(
|
||||
BatchRunRow.forInterest(party.getId(), party.getAgent().getId(), interest, dailyProduct, rate, accrueFrom, jobDate));
|
||||
}
|
||||
}
|
||||
|
||||
private Map<Long, List<PartyTransaction>> movementsByParty(final List<Party> parties, final LocalDate upTo) {
|
||||
final List<Long> partyIds = new ArrayList<>(parties.size());
|
||||
for (final Party party : parties) {
|
||||
partyIds.add(party.getId());
|
||||
}
|
||||
|
||||
// One query for the whole run rather than one per account, then grouped here. The repository
|
||||
// orders by party and date, so each list is already in the order the replay below needs.
|
||||
final List<PartyTransaction> transactions = this.partyTransactionRepository.findForPartiesUpTo(partyIds, upTo);
|
||||
|
||||
final Map<Long, List<PartyTransaction>> byParty = new HashMap<>();
|
||||
for (final PartyTransaction transaction : transactions) {
|
||||
byParty.computeIfAbsent(transaction.getParty().getId(), k -> new ArrayList<>()).add(transaction);
|
||||
}
|
||||
return byParty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns this account's transactions into the plain date/amount pairs the sum needs, with a payout
|
||||
* carried as a negative. The calculation itself lives in DdsInterestCalculation, apart from the
|
||||
* job that runs it, so it can be checked on its own.
|
||||
*/
|
||||
private List<DdsInterestCalculation.Movement> movementsOf(final List<PartyTransaction> transactions) {
|
||||
final List<DdsInterestCalculation.Movement> movements = new ArrayList<>(transactions.size());
|
||||
for (final PartyTransaction transaction : transactions) {
|
||||
final BigDecimal signed = DIRECTION_DEBIT.equals(transaction.getDirection()) ? transaction.getAmount().negate()
|
||||
: transaction.getAmount();
|
||||
movements.add(new DdsInterestCalculation.Movement(transaction.getTransactionDate(), signed));
|
||||
}
|
||||
return movements;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Interest posting
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Lists the interest each account is holding, ready to be credited to its balance. */
|
||||
private void generateInterestPostingRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo, final Long partyFrom,
|
||||
final Long partyTo, final LocalDate jobDate, final boolean odInterest, final Set<String> officeNames,
|
||||
final String subType) {
|
||||
|
||||
final List<Party> parties = odInterest
|
||||
? this.partyRepository.findWithAccruedOdInterest(agentFrom, agentTo, partyFrom, partyTo, subType)
|
||||
: this.partyRepository.findWithAccruedInterest(agentFrom, agentTo, partyFrom, partyTo, subType);
|
||||
|
||||
for (final Party party : parties) {
|
||||
if (!matchesSelectedBranch(party.getAgent().getHomeBranch(), officeNames)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final BigDecimal rate = party.getInterestRate();
|
||||
final BigDecimal balance = party.getCurrentBalance();
|
||||
if (rate == null || rate.compareTo(BigDecimal.ZERO) <= 0 || balance.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final LocalDate accrueFrom = party.getInterestDate() != null ? party.getInterestDate() : party.getOpeningDate();
|
||||
final long days = ChronoUnit.DAYS.between(accrueFrom, jobDate);
|
||||
if (days <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final BigDecimal amount = balance.multiply(rate).multiply(BigDecimal.valueOf(days))
|
||||
.divide(HUNDRED.multiply(DAYS_IN_YEAR), 6, RoundingMode.HALF_UP).setScale(2, RoundingMode.HALF_UP);
|
||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
batchRun.addRow(new BatchRunRow(party.getId(), party.getAgent().getId(), amount));
|
||||
batchRun.addRow(
|
||||
BatchRunRow.forPosting(party.getId(), party.getAgent().getId(), party.accruedInterest(odInterest), jobDate));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Maturity auto close
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Lists matured accounts that can be settled and shut without anybody choosing anything today.
|
||||
*
|
||||
* Two things have to agree before an account is listed. The scheme it is on must have maturity
|
||||
* auto-closing switched on - that is the bank's decision, made once per scheme. And the account
|
||||
* itself must have named where its proceeds go - that is the customer's, made when they opened
|
||||
* it. Neither stands in for the other: a scheme with the switch on cannot send a customer's money
|
||||
* somewhere they never nominated, and a nominated account means nothing on a scheme the bank has
|
||||
* not turned this on for.
|
||||
*
|
||||
* Everything else about the account - that it has matured, still holds money, is not under lien,
|
||||
* has its KYC in order - is decided by the settling itself, which applies the same rules to a
|
||||
* clerk's payout. Repeating them here would be two lists of rules to keep in step.
|
||||
*/
|
||||
private void generateMaturityAutoCloseRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo, final Long partyFrom,
|
||||
final Long partyTo, final LocalDate jobDate, final Set<String> officeNames, final String subType) {
|
||||
|
||||
final Set<String> autoClosingSchemes = schemesWithMaturityAutoClose();
|
||||
if (autoClosingSchemes.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final List<Party> due = this.partyRepository.findDueForMaturityAutoClose(agentFrom, agentTo, partyFrom, partyTo, subType, jobDate);
|
||||
|
||||
for (final Party party : due) {
|
||||
if (!matchesSelectedBranch(party.getAgent().getHomeBranch(), officeNames)) {
|
||||
continue;
|
||||
}
|
||||
if (!autoClosingSchemes.contains(normalisedSubType(party.getSubType()))) {
|
||||
continue;
|
||||
}
|
||||
// The balance as it stands. Interest still to be brought up to the maturity date is added
|
||||
// when the row is applied, so what is listed is the floor of what the customer receives,
|
||||
// never more than it.
|
||||
batchRun.addRow(BatchRunRow.forPosting(party.getId(), party.getAgent().getId(), party.getCurrentBalance(), jobDate));
|
||||
}
|
||||
}
|
||||
|
||||
/** The account-type codes the bank has switched maturity auto-closing on for. */
|
||||
private Set<String> schemesWithMaturityAutoClose() {
|
||||
final Set<String> codes = new HashSet<>();
|
||||
for (final DdsAccountType accountType : this.ddsAccountTypeRepository.findAll()) {
|
||||
if (accountType.isMaturityAutoClose() && accountType.isActive()) {
|
||||
codes.add(normalisedSubType(accountType.getAccountTypeCode()));
|
||||
}
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* An account names its scheme by the code as it was typed, and the two spellings have to meet.
|
||||
* Compared without case or surrounding space for the same reason the payment modes are.
|
||||
*/
|
||||
private String normalisedSubType(final String subType) {
|
||||
return subType == null ? "" : subType.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Commission calculation
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Works out each agent's commission on what they collected since their last commission run.
|
||||
*
|
||||
* The chart gives a percentage on the collection volume; TDS and the agent's standing deductions
|
||||
* come off that, leaving what the agent is actually owed. Both the gross figure and each
|
||||
* deduction are recorded on the row, because that breakdown is what the Agent Commission
|
||||
* Statement prints and what an agent querying their payout needs to see.
|
||||
*/
|
||||
private void generateCommissionRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo, final LocalDate jobDate,
|
||||
final Set<String> officeNames) {
|
||||
|
||||
final List<Agent> agents = this.agentRepository.findBetweenIds(agentFrom, agentTo);
|
||||
|
||||
for (final Agent agent : agents) {
|
||||
if (!matchesSelectedBranch(agent.getHomeBranch(), officeNames)) {
|
||||
continue;
|
||||
}
|
||||
// An agent the bank has switched commission off for, or one no longer collecting, earns
|
||||
// nothing new - but what they have already earned stays owed to them and is still payable.
|
||||
if (!agent.isCommissionCalculation() || !agent.isActive()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final LocalDate fromDate = agent.getLastCommissionDate() != null ? agent.getLastCommissionDate().plusDays(1)
|
||||
: agent.getAgentOpeningDate();
|
||||
@@ -171,57 +424,531 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
continue;
|
||||
}
|
||||
|
||||
final List<CommissionSlab> matchingSlabs = this.commissionSlabRepository.findMatchingSlabs("ACCOUNT", collectionVolume);
|
||||
// A volume that falls in a gap in the chart is a fault in the chart, not a reason to pay
|
||||
// this agent nothing: an agent who worked all month would read exactly like one who
|
||||
// collected nothing, and nobody would notice until they complained.
|
||||
final List<CommissionSlab> matchingSlabs = this.commissionSlabRepository.findMatchingSlabs(ACCOUNT_SLAB, collectionVolume);
|
||||
if (matchingSlabs.isEmpty()) {
|
||||
throw new NoCommissionSlabException(agent.getAgentCode(), collectionVolume);
|
||||
}
|
||||
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(collectionVolume,
|
||||
matchingSlabs.get(0).getCommissionPercent(), agent.isTdsApplicable(), agent.getTdsPercent(),
|
||||
agent.totalDeductions());
|
||||
if (!commission.isPayable()) {
|
||||
// The deductions have swallowed this period's commission. Nothing is owed, and the
|
||||
// agent is not put into debt over it - the deductions are standing figures the bank
|
||||
// adjusts, not a balance this job is entitled to carry forward.
|
||||
continue;
|
||||
}
|
||||
|
||||
final BigDecimal commissionPercent = matchingSlabs.get(0).getCommissionPercent();
|
||||
final BigDecimal amount = collectionVolume.multiply(commissionPercent).divide(HUNDRED, 2, RoundingMode.HALF_UP);
|
||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
batchRun.addRow(new BatchRunRow(null, agent.getId(), amount));
|
||||
batchRun.addRow(BatchRunRow.forCommission(agent.getId(), commission.net(), commission.collectionVolume(),
|
||||
commission.ratePercent(), commission.gross(), commission.tds(), commission.deductions(), fromDate, jobDate));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Commission posting
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Lists what each agent is owed, ready to be paid out. */
|
||||
private void generateCommissionPostingRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo,
|
||||
final LocalDate jobDate, final Set<String> officeNames) {
|
||||
|
||||
final List<Agent> agents = this.agentRepository.findWithCommissionOwed(agentFrom, agentTo);
|
||||
|
||||
for (final Agent agent : agents) {
|
||||
if (!matchesSelectedBranch(agent.getHomeBranch(), officeNames)) {
|
||||
continue;
|
||||
}
|
||||
batchRun.addRow(BatchRunRow.forPosting(null, agent.getId(), agent.getCommission(), jobDate));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Post
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult postBatchRun(final Long batchRunId, final JsonCommand command) {
|
||||
|
||||
final BatchRun batchRun = this.batchRunRepository.findById(batchRunId).orElseThrow(() -> new BatchRunNotFoundException(batchRunId));
|
||||
if (batchRun.isPosted()) {
|
||||
if (batchRun.isPosted() || batchRun.isPosting()) {
|
||||
throw new BatchRunAlreadyPostedException(batchRunId);
|
||||
}
|
||||
|
||||
final boolean isCommissionJob = "COMMISSION_CALCULATION".equals(batchRun.getJobType());
|
||||
final String transactionType = "OD_INTEREST_CALCULATION".equals(batchRun.getJobType()) ? "OD_INTEREST_POST" : "INTEREST_POST";
|
||||
|
||||
for (final BatchRunRow row : batchRun.getRows()) {
|
||||
if (isCommissionJob) {
|
||||
final Agent agent = this.agentRepository.findById(row.getAgentId())
|
||||
.orElseThrow(() -> new AgentNotFoundException(row.getAgentId()));
|
||||
agent.postCommission(row.getAmount(), batchRun.getJobDate());
|
||||
} else {
|
||||
final Party party = this.partyRepository.findById(row.getPartyId())
|
||||
.orElseThrow(() -> new PartyNotFoundException(row.getPartyId()));
|
||||
final Agent agent = party.getAgent();
|
||||
final BigDecimal newBalance = party.credit(row.getAmount());
|
||||
party.markInterestCalculatedThrough(batchRun.getJobDate());
|
||||
|
||||
final PartyTransaction transaction = new PartyTransaction(party, agent, batchRun.getJobDate(), transactionType,
|
||||
"CREDIT", null, null, null, row.getAmount(), null, null, "DDS batch run #" + batchRun.getId(), newBalance);
|
||||
this.partyTransactionRepository.save(transaction);
|
||||
}
|
||||
// Only the crediting runs is worth taking off the request. A calculation run writes a figure
|
||||
// against each account and finishes in under a second on four hundred of them; sending it away
|
||||
// would only mean the posting run that follows could be generated before it had finished, and
|
||||
// find nothing accrued to pay.
|
||||
if (!isCrediting(batchRun.getJobType())) {
|
||||
applyRows(batchRun);
|
||||
batchRun.markPosted(LocalDate.now());
|
||||
this.batchRunRepository.saveAndFlush(batchRun);
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(batchRunId).build();
|
||||
}
|
||||
|
||||
batchRun.markPosted(LocalDate.now());
|
||||
// Marked under way and committed before the work starts, so a second press finds it already
|
||||
// running rather than crediting every account twice.
|
||||
batchRun.markPosting();
|
||||
this.batchRunRepository.saveAndFlush(batchRun);
|
||||
|
||||
// The crediting itself runs away from this request. Each account costs what a savings deposit
|
||||
// costs, and a branch of any size would leave the clerk watching a still screen with no way to
|
||||
// tell a slow run from a hung one. The context a background thread does not inherit is handed
|
||||
// over with it: the tenant, or there is no database; the business date, or the postings are
|
||||
// dated by the wall clock; the signed-in user, or every row fails its audit column.
|
||||
this.postingRunner.post(batchRunId, ThreadLocalContextUtil.getTenant(), ThreadLocalContextUtil.getBusinessDates(),
|
||||
ThreadLocalContextUtil.getDataSourceContext(), SecurityContextHolder.getContext());
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(batchRunId) //
|
||||
.build();
|
||||
}
|
||||
|
||||
/** The runs that move money into accounts, and so carry the cost of a savings deposit on each one. */
|
||||
private boolean isCrediting(final String jobType) {
|
||||
return INTEREST_POSTING.equals(jobType) || OD_INTEREST_POSTING.equals(jobType) || COMMISSION_POSTING.equals(jobType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits every row on the run and marks it posted. Called from the background thread.
|
||||
*
|
||||
* One transaction for the whole run: either every account on it is credited or none is. A part-
|
||||
* credited run would leave the branch to work out by hand which customers had been paid and which
|
||||
* had not, and that is a worse position to be in than simply running it again.
|
||||
*/
|
||||
@Transactional
|
||||
@Override
|
||||
public void applyAndMarkPosted(final Long batchRunId) {
|
||||
final BatchRun batchRun = this.batchRunRepository.findById(batchRunId).orElseThrow(() -> new BatchRunNotFoundException(batchRunId));
|
||||
applyRows(batchRun);
|
||||
batchRun.markPosted(LocalDate.now());
|
||||
this.batchRunRepository.saveAndFlush(batchRun);
|
||||
}
|
||||
|
||||
/** Writes onto the run why it stopped, in a transaction of its own - the failed one has rolled back. */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
@Override
|
||||
public void markPostingFailed(final Long batchRunId, final String reason) {
|
||||
this.batchRunRepository.findById(batchRunId).ifPresent(run -> {
|
||||
run.markFailed(reason);
|
||||
this.batchRunRepository.saveAndFlush(run);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies exactly the rows a Generate call worked out. Shared by the screen's Post button and by
|
||||
* the scheduled job, so an automatic posting goes through the same code as a manual one rather
|
||||
* than a second implementation that could drift from it.
|
||||
*/
|
||||
private void applyRows(final BatchRun batchRun) {
|
||||
final String jobType = batchRun.getJobType();
|
||||
final LocalDate jobDate = batchRun.getJobDate();
|
||||
|
||||
int done = 0;
|
||||
for (final BatchRunRow row : batchRun.getRows()) {
|
||||
switch (jobType) {
|
||||
case INTEREST_CALCULATION -> accrue(row, jobDate, false);
|
||||
case OD_INTEREST_CALCULATION -> accrue(row, jobDate, true);
|
||||
case INTEREST_POSTING -> postInterest(row, batchRun, false);
|
||||
case OD_INTEREST_POSTING -> postInterest(row, batchRun, true);
|
||||
case COMMISSION_CALCULATION -> accrueCommission(row, jobDate);
|
||||
case COMMISSION_POSTING -> payCommission(row, batchRun);
|
||||
case MATURITY_AUTO_CLOSE -> closeAtMaturity(row);
|
||||
default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType);
|
||||
}
|
||||
done++;
|
||||
}
|
||||
|
||||
// Only at the end. A crediting run is one transaction from start to finish, so a count written
|
||||
// part-way through would be invisible outside it until the run commits - and writing it from a
|
||||
// transaction of its own would have two of them contending for the same row. The status says
|
||||
// POSTING while it works, which is what a clerk actually needs to know.
|
||||
batchRun.recordProgress(done);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles and shuts one matured account, through the same code a clerk's payout goes through.
|
||||
*
|
||||
* The row carries no destination and none is taken from it: the account is read again now and its
|
||||
* own nominated account used. A run generated last week and posted today would otherwise pay into
|
||||
* whatever was nominated last week, which is exactly the instruction the customer may since have
|
||||
* changed.
|
||||
*
|
||||
* Nothing is caught here. If one account cannot be settled - it was closed in the meantime, a
|
||||
* lien was placed on it, its nominated account was shut - the whole run rolls back and the clerk
|
||||
* is told why, which is how every other posting run behaves. A run that quietly settled nine of
|
||||
* ten accounts would leave somebody to work out which one it was.
|
||||
*/
|
||||
private void closeAtMaturity(final BatchRunRow row) {
|
||||
this.partyWritePlatformService.getObject().closeAtMaturity(row.getPartyId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parks the calculated interest against the account and records how far it is calculated through.
|
||||
* No ledger entry: nothing has moved yet, and the customer's balance is untouched.
|
||||
*
|
||||
* The through-date is checked again here, not just when the row was worked out. Generating a run
|
||||
* does not stamp anything, so two runs can be generated over the same period and each would look
|
||||
* valid on its own - posting both would earn the same interest twice. Whichever posts first moves
|
||||
* the through-date past the second's period, and the second finds its work already done.
|
||||
*/
|
||||
private void accrue(final BatchRunRow row, final LocalDate jobDate, final boolean odInterest) {
|
||||
final Party party = partyOf(row.getPartyId());
|
||||
|
||||
final LocalDate calculatedThrough = party.interestCalculatedThrough(odInterest);
|
||||
if (calculatedThrough != null && !calculatedThrough.isBefore(row.getToDate())) {
|
||||
return;
|
||||
}
|
||||
|
||||
party.accrueInterest(row.getAmount(), jobDate, odInterest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits the parked interest to the customer's balance and records it on the ledger.
|
||||
*
|
||||
* Only what the account is actually holding can be credited: two posting runs generated over the
|
||||
* same accrued interest would each list it in full, and paying both would credit interest that
|
||||
* was never earned.
|
||||
*/
|
||||
private void postInterest(final BatchRunRow row, final BatchRun batchRun, final boolean odInterest) {
|
||||
final Party party = partyOf(row.getPartyId());
|
||||
if (party.accruedInterest(odInterest).compareTo(row.getAmount()) < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final BigDecimal newBalance = party.postInterest(row.getAmount(), odInterest);
|
||||
|
||||
// The same interest on the bank's own books. A party opened before DDS began opening real
|
||||
// accounts has none, and keeps its DDS balance alone rather than the run refusing to pay
|
||||
// everybody because of one old record.
|
||||
if (party.getSavingsAccountId() != null) {
|
||||
this.ddsLedger.postInterest(party.getSavingsAccountId(), batchRun.getJobDate(), row.getAmount(),
|
||||
(odInterest ? "DDS OD interest" : "DDS interest") + ", batch run #" + batchRun.getId());
|
||||
}
|
||||
|
||||
final String transactionType = odInterest ? "OD_INTEREST_POST" : "INTEREST_POST";
|
||||
this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), batchRun.getJobDate(), transactionType,
|
||||
DIRECTION_CREDIT, null, null, null, row.getAmount(), null, null, "DDS batch run #" + batchRun.getId(), newBalance));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds what the agent earned to what they are owed, and withholds the TDS that went with it.
|
||||
* Guarded the same way as interest accrual: a period already calculated through is not paid again.
|
||||
*/
|
||||
private void accrueCommission(final BatchRunRow row, final LocalDate jobDate) {
|
||||
final Agent agent = agentOf(row.getAgentId());
|
||||
|
||||
final LocalDate calculatedThrough = agent.getLastCommissionDate();
|
||||
if (calculatedThrough != null && !calculatedThrough.isBefore(row.getToDate())) {
|
||||
return;
|
||||
}
|
||||
|
||||
final BigDecimal tds = row.getTdsAmount() == null ? BigDecimal.ZERO : row.getTdsAmount();
|
||||
agent.accrueCommission(row.getAmount(), tds, jobDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pays the agent what they are owed and records it on the ledger.
|
||||
*
|
||||
* The agent's DDS balance is deliberately not touched: that figure is cash collected from
|
||||
* customers and not yet handed to a branch, which commission has nothing to do with. The entry is
|
||||
* recorded against the balance as it stands, so the ledger reads consistently.
|
||||
*/
|
||||
private void payCommission(final BatchRunRow row, final BatchRun batchRun) {
|
||||
final Agent agent = agentOf(row.getAgentId());
|
||||
// Only what is actually owed can be paid: two posting runs generated over the same owed
|
||||
// figure would each list it in full, and paying both would pay commission twice.
|
||||
if (agent.getCommission().compareTo(row.getAmount()) < 0) {
|
||||
return;
|
||||
}
|
||||
agent.payOutCommission(row.getAmount());
|
||||
|
||||
creditCommissionToSavings(agent, row.getAmount(), batchRun);
|
||||
|
||||
this.partyTransactionRepository.save(new PartyTransaction(null, agent, batchRun.getJobDate(), "COMMISSION_POST", DIRECTION_CREDIT,
|
||||
null, null, agent.getAgentSavingAccountNo(), row.getAmount(), null, null, "DDS batch run #" + batchRun.getId(),
|
||||
agent.getAgentCurrentBalance()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the commission into the agent's own savings account.
|
||||
*
|
||||
* Until this ran, paying commission only zeroed a figure on the agent row: the money was "paid"
|
||||
* inside DDS and never arrived anywhere the agent could draw on, and no ledger entry was made.
|
||||
*
|
||||
* The account is checked again here rather than trusted from when it was chosen. It may have been
|
||||
* closed since, and agents on the books before those checks existed still carry accounts that
|
||||
* never passed them. Every reason it cannot take the credit is named, because the alternative is
|
||||
* what this replaced: the deposit was simply attempted, and the savings module answered with
|
||||
* "Transaction is not allowed. Account is not active" - true, but it tells an officer nothing
|
||||
* about which agent, which account, or what to do about it.
|
||||
*
|
||||
* Throwing rolls the whole run back. A commission run that cannot pay one agent must not half-pay
|
||||
* the rest.
|
||||
*/
|
||||
private void creditCommissionToSavings(final Agent agent, final BigDecimal amount, final BatchRun batchRun) {
|
||||
final String accountNo = agent.getAgentSavingAccountNo();
|
||||
if (accountNo == null || accountNo.isBlank()) {
|
||||
throw new AgentCommissionAccountUnusableException(agent.getAgentCode(), "no savings account is on file for them");
|
||||
}
|
||||
|
||||
// The account's own row, not the entity: loading the entity would drag its product in with it,
|
||||
// and the ledger loads the account properly for itself anyway.
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(COMMISSION_ACCOUNT_SQL, accountNo); // NOSONAR
|
||||
if (rows.isEmpty()) {
|
||||
throw new AgentCommissionAccountUnusableException(agent.getAgentCode(),
|
||||
"no account with the number " + accountNo + " exists, so the commission has nowhere to go");
|
||||
}
|
||||
|
||||
final Map<String, Object> account = rows.get(0);
|
||||
final int status = ((Number) account.get("status_enum")).intValue();
|
||||
final int depositType = ((Number) account.get("deposit_type_enum")).intValue();
|
||||
final Long productId = ((Number) account.get("product_id")).longValue();
|
||||
|
||||
if (depositType != SAVINGS_DEPOSIT_TYPE) {
|
||||
throw new AgentCommissionAccountUnusableException(agent.getAgentCode(), "account " + accountNo + " is a "
|
||||
+ (depositType == FIXED_DEPOSIT_TYPE ? "fixed" : "recurring") + " deposit, which cannot take a commission credit."
|
||||
+ " Set an ordinary savings account for this agent");
|
||||
}
|
||||
|
||||
final List<Long> ddsProduct = this.jdbcTemplate.queryForList(DDS_DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR
|
||||
if (!ddsProduct.isEmpty() && productId.equals(ddsProduct.get(0))) {
|
||||
throw new AgentCommissionAccountUnusableException(agent.getAgentCode(), "account " + accountNo
|
||||
+ " is a DDS scheme account belonging to a customer, so commission paid into it would be counted as their"
|
||||
+ " deposits. Set an ordinary savings account for this agent");
|
||||
}
|
||||
|
||||
// Active or dormant: a dormant account is still open and a deposit wakes it up. Everything
|
||||
// else - awaiting approval, closed, rejected - cannot take one, and this says which it is.
|
||||
if (status != ACTIVE_SAVINGS_STATUS && status != DORMANT_SAVINGS_STATUS) {
|
||||
throw new AgentCommissionAccountUnusableException(agent.getAgentCode(),
|
||||
"account " + accountNo + " is " + statusName(status) + " rather than active, so it cannot take a deposit yet");
|
||||
}
|
||||
|
||||
this.ddsLedger.payCommission(((Number) account.get("id")).longValue(), batchRun.getJobDate(), amount,
|
||||
"DDS commission, batch run #" + batchRun.getId());
|
||||
}
|
||||
|
||||
/** Fineract's savings statuses, named the way the screens name them. */
|
||||
private String statusName(final int status) {
|
||||
return switch (status) {
|
||||
case 100 -> "still awaiting approval";
|
||||
case 200 -> "approved but not yet activated";
|
||||
case 303 -> "dormant";
|
||||
case 304 -> "escheated";
|
||||
case 400 -> "withdrawn";
|
||||
case 500 -> "rejected";
|
||||
case 600 -> "closed";
|
||||
default -> "in state " + status;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Automatic posting - the Account Type screen's "Auto Interest Post" setting
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Posts interest for every scheme that is due today.
|
||||
*
|
||||
* A scheme that posts automatically has decided it needs no review, so this does both halves in
|
||||
* one go: it calculates the interest since the last run and then posts it into the customers'
|
||||
* balances. Both halves go through the same code the screen's Post button uses, and both leave a
|
||||
* run behind, so an automatic posting is as visible and as auditable as a manual one.
|
||||
*
|
||||
* Runs are scoped to one scheme at a time, because the posting day belongs to the scheme.
|
||||
*/
|
||||
@Transactional
|
||||
@Override
|
||||
public int postDueAutoInterest(final LocalDate businessDate) {
|
||||
int posted = 0;
|
||||
|
||||
for (final DdsAccountType accountType : this.ddsAccountTypeRepository.findDueForAutoInterestPost()) {
|
||||
if (!isDueOn(accountType, businessDate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final String subType = accountType.getAccountTypeCode();
|
||||
calculateAndPost(INTEREST_CALCULATION, businessDate, subType);
|
||||
calculateAndPost(INTEREST_POSTING, businessDate, subType);
|
||||
posted++;
|
||||
}
|
||||
|
||||
return posted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this scheme's posting day falls on the given date.
|
||||
*
|
||||
* A day beyond the length of the month means the end of that month: a scheme set to 31 posts on
|
||||
* 31 January, 28 February and 30 April. Without that, February would simply be skipped and the
|
||||
* account would lose a month's interest every year.
|
||||
*/
|
||||
private boolean isDueOn(final DdsAccountType accountType, final LocalDate date) {
|
||||
if (!monthIsDue(accountType.getAutoInterestPostWhen(), date)) {
|
||||
return false;
|
||||
}
|
||||
final int configuredDay = accountType.getAutoInterestPostDay();
|
||||
final int effectiveDay = Math.min(configuredDay, date.lengthOfMonth());
|
||||
return date.getDayOfMonth() == effectiveDay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given month is one this frequency posts in.
|
||||
*
|
||||
* Monthly is every month. The rest follow the Indian financial year, so quarters close in June,
|
||||
* September, December and March, half-years in September and March, and the year in March.
|
||||
* <p>
|
||||
* Every scheme in use today is Monthly; the others are here so the setting is not silently
|
||||
* ignored, and the month choice for them is worth confirming with the business before any scheme
|
||||
* relies on it.
|
||||
*/
|
||||
private boolean monthIsDue(final String frequency, final LocalDate date) {
|
||||
final int month = date.getMonthValue();
|
||||
return switch (frequency == null ? "Monthly" : frequency) {
|
||||
case "Quarterly" -> month == 6 || month == 9 || month == 12 || month == 3;
|
||||
case "Half Yearly" -> month == 9 || month == 3;
|
||||
case "Yearly" -> month == 3;
|
||||
default -> true;
|
||||
};
|
||||
}
|
||||
|
||||
/** One half of an automatic posting: work the rows out, save them, then apply them. */
|
||||
/**
|
||||
* Brings every matured account's interest up to its maturity date.
|
||||
*
|
||||
* Interest otherwise arrives only when a posting run happens, which is monthly, set per scheme,
|
||||
* and can be switched off - so a term ending between two runs leaves its last stretch uncredited
|
||||
* and a payout made in the meantime pays the customer less than they earned. Every account on a
|
||||
* scheme with automatic posting turned off had exactly that gap.
|
||||
*
|
||||
* Maturity is the one date that cannot wait for the next run, so it is done here, on the night
|
||||
* after each account's term ends, whatever its scheme says about automatic posting. By the time
|
||||
* the account reaches the clerk's list in the morning it is already whole, and the figure on
|
||||
* screen is the figure that will be paid.
|
||||
*/
|
||||
@Transactional
|
||||
@Override
|
||||
public int postMaturityInterest(final LocalDate businessDate) {
|
||||
int posted = 0;
|
||||
for (final Party party : this.partyRepository.findMaturedWithInterestOutstanding(businessDate)) {
|
||||
if (postInterestThrough(party, party.getMaturityDate()).compareTo(BigDecimal.ZERO) > 0) {
|
||||
posted++;
|
||||
}
|
||||
}
|
||||
return posted;
|
||||
}
|
||||
|
||||
/** The holder's own KYC, read from the bank's record rather than anything DDS keeps of its own. */
|
||||
private static final String KYC_VERIFIED_SQL = "select count(*) from m_client_kyc "
|
||||
+ "where client_id = ? and kyc_status = 300 and (expiry_date is null or expiry_date >= current_date)";
|
||||
|
||||
private static final int MAJORITY_AGE = 18;
|
||||
|
||||
/**
|
||||
* Turns over the minors' accounts whose holders have come of age and been identified.
|
||||
*
|
||||
* A minor's account is operated by a guardian because a minor cannot contract. At eighteen that
|
||||
* stops being true: the customer holds the account in their own right and the guardian's
|
||||
* authority ends. Left as it was, the bank would go on dealing with somebody who no longer stands
|
||||
* for them.
|
||||
*
|
||||
* The birthday alone is not enough. An eighteen-year-old the bank has never identified is somebody
|
||||
* it cannot pay, so the turnover waits on their own KYC being verified - and until it is, the
|
||||
* payout refuses while collections carry on. Nothing is written for an account still waiting.
|
||||
*/
|
||||
@Transactional
|
||||
@Override
|
||||
public int promoteMinorsWhoHaveComeOfAge(final LocalDate businessDate) {
|
||||
final LocalDate bornOnOrBefore = businessDate.minusYears(MAJORITY_AGE);
|
||||
int promoted = 0;
|
||||
for (final Party party : this.partyRepository.findMinorsWhoHaveComeOfAge(bornOnOrBefore)) {
|
||||
final Integer verified = this.jdbcTemplate.queryForObject(KYC_VERIFIED_SQL, Integer.class, party.clientId()); // NOSONAR
|
||||
if (verified == null || verified == 0) {
|
||||
continue;
|
||||
}
|
||||
party.becomeAdult();
|
||||
this.partyRepository.save(party);
|
||||
promoted++;
|
||||
}
|
||||
return promoted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Works out a single account's interest up to a date and credits it, returning what was paid.
|
||||
*
|
||||
* The same daily-product arithmetic the batch uses - the calculation lives in one class so the
|
||||
* figure cannot depend on which route asked for it. What differs is only the reach: a run works
|
||||
* to a common job date across many accounts, this works to one account's own date.
|
||||
*
|
||||
* Returns zero, having done nothing, when interest is already current or the sum rounds away.
|
||||
* Nothing is written in that case - an account that owes nothing should not carry a nil posting.
|
||||
*/
|
||||
@Transactional
|
||||
@Override
|
||||
public BigDecimal postInterestThrough(final Party party, final LocalDate through) {
|
||||
final BigDecimal rate = party.getInterestRate();
|
||||
if (through == null || rate == null || rate.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
final LocalDate accrueFrom = party.interestCalculatedThrough(false) != null ? party.interestCalculatedThrough(false)
|
||||
: party.getOpeningDate();
|
||||
if (!accrueFrom.isBefore(through)) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
final List<PartyTransaction> movements = this.partyTransactionRepository.findForPartiesUpTo(List.of(party.getId()), through);
|
||||
final BigDecimal dailyProduct = DdsInterestCalculation.dailyProduct(party.getOpeningBalance(), movementsOf(movements), accrueFrom,
|
||||
through);
|
||||
final BigDecimal interest = DdsInterestCalculation.interestOn(dailyProduct, rate);
|
||||
if (interest.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
party.accrueInterest(interest, through, false);
|
||||
final BigDecimal newBalance = party.postInterest(interest, false);
|
||||
this.partyRepository.saveAndFlush(party);
|
||||
|
||||
// The same interest on the bank's own books. A party opened before DDS began opening real
|
||||
// accounts has none, and keeps its DDS balance alone rather than the posting being refused.
|
||||
if (party.getSavingsAccountId() != null) {
|
||||
this.ddsLedger.postInterest(party.getSavingsAccountId(), through, interest, "DDS interest to maturity");
|
||||
}
|
||||
|
||||
this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), through, "INTEREST_POST", DIRECTION_CREDIT, null,
|
||||
null, null, interest, null, null, "Interest to maturity", newBalance));
|
||||
|
||||
return interest;
|
||||
}
|
||||
|
||||
private void calculateAndPost(final String jobType, final LocalDate jobDate, final String subType) {
|
||||
final BatchRun batchRun = new BatchRun(jobType, "All", jobDate, 1L, Long.MAX_VALUE, 1L, Long.MAX_VALUE, LocalDate.now(),
|
||||
subType);
|
||||
|
||||
if (INTEREST_CALCULATION.equals(jobType)) {
|
||||
generateInterestRows(batchRun, 1L, Long.MAX_VALUE, 1L, Long.MAX_VALUE, jobDate, false, null, subType);
|
||||
} else {
|
||||
generateInterestPostingRows(batchRun, 1L, Long.MAX_VALUE, 1L, Long.MAX_VALUE, jobDate, false, null, subType);
|
||||
}
|
||||
|
||||
this.batchRunRepository.saveAndFlush(batchRun);
|
||||
|
||||
// A run that found nothing still gets recorded and marked posted, so the log shows the job
|
||||
// looked and there was nothing to do - rather than leaving a run that reads as unfinished.
|
||||
applyRows(batchRun);
|
||||
batchRun.markPosted(LocalDate.now());
|
||||
this.batchRunRepository.saveAndFlush(batchRun);
|
||||
}
|
||||
|
||||
private Party partyOf(final Long partyId) {
|
||||
return this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId));
|
||||
}
|
||||
|
||||
private Agent agentOf(final Long agentId) {
|
||||
return this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
|
||||
}
|
||||
}
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.batch.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
||||
import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType;
|
||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Runs a posting batch away from the request that asked for it.
|
||||
*
|
||||
* Posting credits every account on the run one at a time, and each credit costs what a savings
|
||||
* deposit costs - the account is loaded with its transactions and its running balances worked out
|
||||
* again. Measured on this build, four hundred accounts carrying ninety days of collections took two
|
||||
* minutes, and the cost climbs faster than the count: twice the accounts took four and a half times
|
||||
* as long. A branch of any size would leave the clerk watching a still screen, unable to tell a slow
|
||||
* run from a hung one, and pressing the button again.
|
||||
*
|
||||
* So the request returns as soon as the run is marked under way, and the crediting happens here. The
|
||||
* arithmetic is untouched - the same rows, the same amounts, the same ledger entries. What changes is
|
||||
* only who waits for it.
|
||||
*
|
||||
* A background thread starts with none of the context a request carries, and three pieces of it
|
||||
* matter. Without the tenant there is no database to write to at all. Without the business date the
|
||||
* postings would be dated by the wall clock rather than the day the bank is working on. Without the
|
||||
* signed-in user every row would fail its not-null audit column. All three are taken from the calling
|
||||
* thread and put back here.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DdsBatchPostingRunner {
|
||||
|
||||
/**
|
||||
* Deferred on purpose. The service hands work to this runner and the runner calls back into it,
|
||||
* so resolving either eagerly would have them waiting on each other at startup.
|
||||
*/
|
||||
private final ObjectProvider<BatchRunWritePlatformService> batchRunService;
|
||||
|
||||
@Async
|
||||
public void post(final Long batchRunId, final FineractPlatformTenant tenant, final HashMap<BusinessDateType, LocalDate> businessDates,
|
||||
final String dataSourceContext, final SecurityContext securityContext) {
|
||||
|
||||
ThreadLocalContextUtil.setTenant(tenant);
|
||||
ThreadLocalContextUtil.setBusinessDates(businessDates);
|
||||
if (dataSourceContext != null) {
|
||||
ThreadLocalContextUtil.setDataSourceContext(dataSourceContext);
|
||||
}
|
||||
SecurityContextHolder.setContext(securityContext);
|
||||
|
||||
try {
|
||||
this.batchRunService.getObject().applyAndMarkPosted(batchRunId);
|
||||
log.info("DDS batch run #{} posted", batchRunId);
|
||||
} catch (final Exception e) {
|
||||
// Recorded against the run rather than only logged: a run that dies in the background has
|
||||
// nowhere else to say so, and a clerk who is not told simply presses the button again.
|
||||
log.error("DDS batch run #{} failed while posting", batchRunId, e);
|
||||
markFailedQuietly(batchRunId, e);
|
||||
} finally {
|
||||
// The pool hands this thread on to whatever comes next, so everything set above has to go
|
||||
// - a tenant left behind would have the next run writing to the wrong bank's database.
|
||||
SecurityContextHolder.clearContext();
|
||||
ThreadLocalContextUtil.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the reason onto the run, in a transaction of its own.
|
||||
*
|
||||
* The one that failed has rolled back, so this needs a fresh one - and if even that cannot be
|
||||
* written there is nothing further to be done but log it, since throwing from here would lose the
|
||||
* original failure.
|
||||
*/
|
||||
private void markFailedQuietly(final Long batchRunId, final Exception cause) {
|
||||
try {
|
||||
this.batchRunService.getObject().markPostingFailed(batchRunId, cause.getMessage());
|
||||
} catch (final Exception ignored) {
|
||||
log.error("DDS batch run #{} could not record its failure", batchRunId, ignored);
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -20,14 +20,19 @@ package org.apache.fineract.portfolio.dds.batch.starter;
|
||||
|
||||
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
|
||||
import org.apache.fineract.organisation.agent.domain.AgentRepository;
|
||||
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountTypeRepository;
|
||||
import org.apache.fineract.portfolio.dds.batch.domain.BatchRunRepository;
|
||||
import org.apache.fineract.portfolio.dds.batch.serialization.BatchRunCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.DdsBatchPostingRunner;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunReadPlatformService;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformServiceJpaRepositoryImpl;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
|
||||
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
|
||||
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -46,8 +51,11 @@ public class DdsBatchConfiguration {
|
||||
@ConditionalOnMissingBean(BatchRunWritePlatformService.class)
|
||||
public BatchRunWritePlatformService batchRunWritePlatformService(BatchRunCommandFromApiJsonDeserializer fromApiJsonDeserializer,
|
||||
BatchRunRepository batchRunRepository, PartyRepository partyRepository, AgentRepository agentRepository,
|
||||
PartyTransactionRepository partyTransactionRepository, CommissionSlabRepository commissionSlabRepository) {
|
||||
PartyTransactionRepository partyTransactionRepository, CommissionSlabRepository commissionSlabRepository,
|
||||
DdsAccountTypeRepository ddsAccountTypeRepository, DdsLedger ddsLedger, DdsBatchPostingRunner postingRunner,
|
||||
JdbcTemplate jdbcTemplate, ObjectProvider<PartyWritePlatformService> partyWritePlatformService) {
|
||||
return new BatchRunWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, batchRunRepository, partyRepository,
|
||||
agentRepository, partyTransactionRepository, commissionSlabRepository);
|
||||
agentRepository, partyTransactionRepository, commissionSlabRepository, ddsAccountTypeRepository, ddsLedger, postingRunner,
|
||||
jdbcTemplate, partyWritePlatformService);
|
||||
}
|
||||
}
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.LocalDate;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.dds.collection.data.AgentCollectionSummaryData;
|
||||
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/dds/collectionentries")
|
||||
@Component
|
||||
@Tag(name = "DDS Collection Entries", description = "What an agent wrote down on their round, before the branch has counted the cash. "
|
||||
+ "These rows move no money: no customer is credited, no agent's cash recorded and no ledger entry written until a clerk settles "
|
||||
+ "the agent's day, and what that writes is an ordinary DDS collection.")
|
||||
@RequiredArgsConstructor
|
||||
public class CollectionEntriesApiResource {
|
||||
|
||||
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DDSCOLLECTIONENTRY";
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final CollectionEntryReadPlatformService readPlatformService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "An agent's written-down collections for a date", description = "Returns the agent's entries for the date with "
|
||||
+ "what they add up to. The agent's own screen shows it back as the record of their round; the counter reads the same figures "
|
||||
+ "to count cash against. Defaults to today when no date is given.")
|
||||
public AgentCollectionSummaryData retrieveAgentDay(@QueryParam("agentId") @Parameter(description = "agentId") final Long agentId,
|
||||
@QueryParam("entryDate") @Parameter(description = "entryDate as yyyy-MM-dd") final String entryDate) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
final LocalDate date = entryDate == null || entryDate.isBlank() ? DateUtils.getBusinessLocalDate() : LocalDate.parse(entryDate);
|
||||
return readPlatformService.retrieveAgentDay(agentId, date);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Write down collections", description = "Mandatory: agentId, entryDate, entries (array of {partyCode, amount, "
|
||||
+ "transactionMode, chequeNo, tokenNo, description}). Nothing is credited and no cash is recorded - this is the agent's note, "
|
||||
+ "and it stands on its own until the day is settled.")
|
||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.createDdsCollectionEntries() //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("settle")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Settle an agent's day", description = "Mandatory: agentId, entryDate. Posts every outstanding entry for that "
|
||||
+ "agent and date as a real collection - all of them or none. No amount is accepted: what is settled is what the entries say.")
|
||||
public CommandProcessingResult settle(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.settleDdsCollectionEntries() //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("{entryId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Take back a written-down collection", description = "Cancels an entry that has not been settled. A settled "
|
||||
+ "entry stands behind a real credit and is reversed on the ledger, not withdrawn here.")
|
||||
public CommandProcessingResult cancel(@PathParam("entryId") @Parameter(description = "entryId") final Long entryId) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.cancelDdsCollectionEntry(entryId) //
|
||||
.withJson("{}") //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.domain;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface CollectionEntryRepository extends JpaRepository<CollectionEntry, Long> {
|
||||
|
||||
/**
|
||||
* What this agent still has outstanding on this date - the list a clerk counts the cash against,
|
||||
* and the list the agent's own screen shows back to them.
|
||||
*
|
||||
* Ordered by id, which is the order they were written in. An agent checking their round against
|
||||
* their bag reads it the way they walked it.
|
||||
*/
|
||||
@Query("select e from CollectionEntry e where e.agent.id = :agentId and e.entryDate = :entryDate "
|
||||
+ "and e.status = 'PENDING' order by e.id")
|
||||
List<CollectionEntry> findPendingFor(@Param("agentId") Long agentId, @Param("entryDate") LocalDate entryDate);
|
||||
|
||||
/**
|
||||
* Everything written for this agent on this date, settled or not.
|
||||
*
|
||||
* Separate from the pending list because the two answer different questions. A clerk counting
|
||||
* cash wants only what is outstanding; an agent looking back at their day wants the whole day,
|
||||
* including what has already been handed in.
|
||||
*/
|
||||
@Query("select e from CollectionEntry e where e.agent.id = :agentId and e.entryDate = :entryDate order by e.id")
|
||||
List<CollectionEntry> findAllFor(@Param("agentId") Long agentId, @Param("entryDate") LocalDate entryDate);
|
||||
|
||||
/**
|
||||
* Whether this account already has money written against it on this date.
|
||||
*
|
||||
* A daily scheme takes one instalment a day, so a second line for the same account on the same
|
||||
* day is nearly always the same collection entered twice - a dropped connection, a retried tap.
|
||||
* Caught here rather than let through, because two lines become two credits the moment a clerk
|
||||
* confirms, and by then the customer's balance is wrong in a way only a reversal can fix.
|
||||
*/
|
||||
@Query("select count(e) from CollectionEntry e where e.party.id = :partyId and e.entryDate = :entryDate "
|
||||
+ "and e.status <> 'CANCELLED'")
|
||||
long countLiveEntriesFor(@Param("partyId") Long partyId, @Param("entryDate") LocalDate entryDate);
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "DDSCOLLECTIONENTRY", action = "CANCEL")
|
||||
public class CancelCollectionEntryCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final CollectionEntryWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public CancelCollectionEntryCommandHandler(final CollectionEntryWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.cancelEntry(command.entityId(), command);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "DDSCOLLECTIONENTRY", action = "CREATE")
|
||||
public class CreateCollectionEntriesCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final CollectionEntryWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public CreateCollectionEntriesCommandHandler(final CollectionEntryWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.createEntries(command);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "DDSCOLLECTIONENTRY", action = "SETTLE")
|
||||
public class SettleCollectionEntriesCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final CollectionEntryWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public SettleCollectionEntriesCommandHandler(final CollectionEntryWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.settleAgentDay(command);
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.serialization;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import java.lang.reflect.Type;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.portfolio.dds.transaction.domain.DdsTransactionMode;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* What a written-down collection has to say before it is worth writing down.
|
||||
*
|
||||
* Deliberately narrow. This is the agent's own note, not a posting, so the questions asked are only
|
||||
* the ones that can be answered away from the branch: is there an account named, an amount, and a
|
||||
* form the money came in. Whether the cash is really there is settled at the counter, and no amount
|
||||
* of checking here would bring that forward.
|
||||
*/
|
||||
@Component
|
||||
public final class CollectionEntryCommandFromApiJsonDeserializer {
|
||||
|
||||
private static final String RESOURCE = "ddscollectionentry";
|
||||
|
||||
private static final String ENTRIES = "entries";
|
||||
|
||||
private static final Set<String> SUPPORTED_CREATE_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList("agentId", "entryDate", ENTRIES, "dateFormat", "locale"));
|
||||
|
||||
private static final Set<String> SUPPORTED_ENTRY_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList("partyCode", "amount", "transactionMode", "chequeNo", "tokenNo", "description"));
|
||||
|
||||
private static final Set<String> SUPPORTED_SETTLE_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList("agentId", "entryDate", "dateFormat", "locale"));
|
||||
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
|
||||
@Autowired
|
||||
public CollectionEntryCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
|
||||
this.fromApiJsonHelper = fromApiJsonHelper;
|
||||
}
|
||||
|
||||
public void validateForCreate(final String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
throw new InvalidJsonException();
|
||||
}
|
||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_CREATE_PARAMETERS);
|
||||
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE);
|
||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
||||
|
||||
v.reset().parameter("agentId").value(this.fromApiJsonHelper.extractLongNamed("agentId", element)).notNull()
|
||||
.integerGreaterThanZero();
|
||||
v.reset().parameter("entryDate").value(this.fromApiJsonHelper.extractLocalDateNamed("entryDate", element)).notNull();
|
||||
|
||||
if (!this.fromApiJsonHelper.parameterExists(ENTRIES, element)) {
|
||||
v.reset().parameter(ENTRIES).value(null).notNull();
|
||||
} else {
|
||||
final var rows = element.getAsJsonObject().getAsJsonArray(ENTRIES);
|
||||
v.reset().parameter(ENTRIES).value(rows).jsonArrayNotEmpty();
|
||||
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
final JsonObject row = rows.get(i).getAsJsonObject();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(row, SUPPORTED_ENTRY_PARAMETERS);
|
||||
|
||||
v.reset().parameter(ENTRIES).parameterAtIndexArray("partyCode", i).value(stringOf(row, "partyCode")).notBlank();
|
||||
|
||||
v.reset().parameter(ENTRIES).parameterAtIndexArray("amount", i).value(decimalOf(row)).notNull().positiveAmount();
|
||||
|
||||
final String mode = stringOf(row, "transactionMode");
|
||||
v.reset().parameter(ENTRIES).parameterAtIndexArray("transactionMode", i).value(mode).notBlank();
|
||||
// The row has to be named in the message: a round is entered a line at a time and the
|
||||
// agent needs to know which line the screen is complaining about.
|
||||
v.reset().parameter(ENTRIES).value(mode).ignoreIfNull().matchesRegularExpression(DdsTransactionMode.acceptedPattern(),
|
||||
"Row " + (i + 1) + ": payment mode must be one of " + DdsTransactionMode.labelsForMessage());
|
||||
|
||||
v.reset().parameter(ENTRIES).parameterAtIndexArray("chequeNo", i).value(stringOf(row, "chequeNo")).ignoreIfNull()
|
||||
.notExceedingLengthOf(50);
|
||||
v.reset().parameter(ENTRIES).parameterAtIndexArray("tokenNo", i).value(stringOf(row, "tokenNo")).ignoreIfNull()
|
||||
.notExceedingLengthOf(50);
|
||||
v.reset().parameter(ENTRIES).parameterAtIndexArray("description", i).value(stringOf(row, "description")).ignoreIfNull()
|
||||
.notExceedingLengthOf(200);
|
||||
}
|
||||
}
|
||||
|
||||
throwIfErrors(errors);
|
||||
}
|
||||
|
||||
public void validateForSettle(final String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
throw new InvalidJsonException();
|
||||
}
|
||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_SETTLE_PARAMETERS);
|
||||
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE);
|
||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
||||
|
||||
v.reset().parameter("agentId").value(this.fromApiJsonHelper.extractLongNamed("agentId", element)).notNull()
|
||||
.integerGreaterThanZero();
|
||||
v.reset().parameter("entryDate").value(this.fromApiJsonHelper.extractLocalDateNamed("entryDate", element)).notNull();
|
||||
|
||||
// No amount is asked for. What is settled is what the entries say, to the rupee - a figure
|
||||
// typed here would be a second opinion about a total the entries already give, and the two
|
||||
// could differ.
|
||||
throwIfErrors(errors);
|
||||
}
|
||||
|
||||
private String stringOf(final JsonObject json, final String field) {
|
||||
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
|
||||
}
|
||||
|
||||
private BigDecimal decimalOf(final JsonObject json) {
|
||||
return json.has("amount") && !json.get("amount").isJsonNull() ? json.get("amount").getAsBigDecimal() : null;
|
||||
}
|
||||
|
||||
private void throwIfErrors(final List<ApiParameterError> errors) {
|
||||
if (!errors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import org.apache.fineract.portfolio.dds.collection.data.AgentCollectionSummaryData;
|
||||
|
||||
public interface CollectionEntryReadPlatformService {
|
||||
|
||||
/**
|
||||
* An agent's written-down collections for a date, with what they add up to.
|
||||
*
|
||||
* One call serves both screens. The agent's own page shows it back to them as the running record
|
||||
* of their round; the clerk's settlement screen reads the same figures to count cash against.
|
||||
* Two calls would be two chances for the two screens to disagree about the same day.
|
||||
*/
|
||||
AgentCollectionSummaryData retrieveAgentDay(Long agentId, LocalDate entryDate);
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
|
||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
||||
import org.apache.fineract.organisation.agent.domain.AgentRepository;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
|
||||
import org.apache.fineract.portfolio.dds.collection.data.AgentCollectionSummaryData;
|
||||
import org.apache.fineract.portfolio.dds.collection.data.CollectionEntryData;
|
||||
import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntry;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class CollectionEntryReadPlatformServiceImpl implements CollectionEntryReadPlatformService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final AgentRepository agentRepository;
|
||||
|
||||
private static final class EntryMapper implements RowMapper<CollectionEntryData> {
|
||||
|
||||
String schema() {
|
||||
// The party is joined in for its code, name and balance: every reader of this list is
|
||||
// checking a line against a person, and an id on its own tells them nothing. The balance
|
||||
// is the account's as it stands - these entries have not been posted, so it does not
|
||||
// include them, and the screens say as much.
|
||||
return " e.id as id, e.agent_id as agentId, a.agent_code as agentCode, a.agent_name as agentName, "
|
||||
+ " e.party_id as partyId, p.party_code as partyCode, p.party_name as partyName, "
|
||||
+ " p.current_balance as partyBalance, p.daily_deposit_amount as dailyDepositAmount, "
|
||||
+ " e.entry_date as entryDate, e.amount as amount, e.transaction_mode as transactionMode, "
|
||||
+ " e.cheque_no as chequeNo, e.token_no as tokenNo, e.description as description, "
|
||||
+ " e.status as status, e.settled_on as settledOn, e.party_transaction_id as partyTransactionId "
|
||||
+ " from m_dds_collection_entry e "
|
||||
+ " join m_dds_agent a on a.id = e.agent_id "
|
||||
+ " join m_dds_party p on p.id = e.party_id";
|
||||
}
|
||||
|
||||
@Override
|
||||
public CollectionEntryData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||
return CollectionEntryData.builder() //
|
||||
.id(rs.getLong("id")) //
|
||||
.agentId(rs.getLong("agentId")) //
|
||||
.agentCode(rs.getString("agentCode")) //
|
||||
.agentName(rs.getString("agentName")) //
|
||||
.partyId(rs.getLong("partyId")) //
|
||||
.partyCode(rs.getString("partyCode")) //
|
||||
.partyName(rs.getString("partyName")) //
|
||||
.partyBalance(rs.getBigDecimal("partyBalance")) //
|
||||
.dailyDepositAmount(rs.getBigDecimal("dailyDepositAmount")) //
|
||||
.entryDate(JdbcSupport.getLocalDate(rs, "entryDate")) //
|
||||
.amount(rs.getBigDecimal("amount")) //
|
||||
.transactionMode(rs.getString("transactionMode")) //
|
||||
.chequeNo(rs.getString("chequeNo")) //
|
||||
.tokenNo(rs.getString("tokenNo")) //
|
||||
.description(rs.getString("description")) //
|
||||
.status(rs.getString("status")) //
|
||||
.settledOn(JdbcSupport.getLocalDate(rs, "settledOn")) //
|
||||
.partyTransactionId(JdbcSupport.getLong(rs, "partyTransactionId")) //
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgentCollectionSummaryData retrieveAgentDay(final Long agentId, final LocalDate entryDate) {
|
||||
// Named first, so an agent id that matches nothing is refused here rather than quietly
|
||||
// returning an empty day - which reads as "collected nothing", and that is an answer a clerk
|
||||
// would act on.
|
||||
final Agent agent = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
|
||||
|
||||
final EntryMapper mapper = new EntryMapper();
|
||||
// Cancelled lines are left out. They were taken back before anything was posted, so they are
|
||||
// not part of the day's record and would only be counted by somebody adding the column up.
|
||||
final String sql = "select " + mapper.schema() + " where e.agent_id = ? and e.entry_date = ? "
|
||||
+ " and e.status <> '" + CollectionEntry.CANCELLED + "' order by e.id";
|
||||
final List<CollectionEntryData> entries = this.jdbcTemplate.query(sql, mapper, agentId, entryDate);
|
||||
|
||||
int pendingCount = 0;
|
||||
int settledCount = 0;
|
||||
BigDecimal expected = BigDecimal.ZERO;
|
||||
BigDecimal settled = BigDecimal.ZERO;
|
||||
|
||||
for (final CollectionEntryData entry : entries) {
|
||||
if (CollectionEntry.PENDING.equals(entry.getStatus())) {
|
||||
pendingCount++;
|
||||
expected = expected.add(entry.getAmount());
|
||||
} else if (CollectionEntry.SETTLED.equals(entry.getStatus())) {
|
||||
settledCount++;
|
||||
settled = settled.add(entry.getAmount());
|
||||
}
|
||||
}
|
||||
|
||||
return AgentCollectionSummaryData.builder() //
|
||||
.agentId(agent.getId()) //
|
||||
.agentCode(agent.getAgentCode()) //
|
||||
.agentName(agent.getAgentName()) //
|
||||
.entryDate(entryDate) //
|
||||
.pendingCount(pendingCount) //
|
||||
.expectedAmount(expected) //
|
||||
.settledCount(settledCount) //
|
||||
.settledAmount(settled) //
|
||||
.entries(entries) //
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.service;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
|
||||
public interface CollectionEntryWritePlatformService {
|
||||
|
||||
/**
|
||||
* Writes down what an agent says they collected. Moves no money whatsoever.
|
||||
*
|
||||
* Accepts one entry or a round of them. The checks are the ones that can be made without the
|
||||
* cash in hand - that the account exists, is open, and is this agent's to collect from - because
|
||||
* an entry the branch will refuse hours later is better refused at the customer's door, while
|
||||
* the agent is still standing there.
|
||||
*/
|
||||
CommandProcessingResult createEntries(JsonCommand command);
|
||||
|
||||
/** Takes back a line that was never posted. Refused once the entry has been settled. */
|
||||
CommandProcessingResult cancelEntry(Long entryId, JsonCommand command);
|
||||
|
||||
/**
|
||||
* Turns an agent's outstanding entries for a date into real credits, all together.
|
||||
*
|
||||
* This is the moment the money becomes the bank's business: the clerk has the cash in front of
|
||||
* them, counted against what the entries add up to. All or none - a settlement that posted half
|
||||
* a round would leave the branch holding cash it could not account for against a list that no
|
||||
* longer matched.
|
||||
*/
|
||||
CommandProcessingResult settleAgentDay(JsonCommand command);
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.service;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
||||
import org.apache.fineract.organisation.agent.domain.AgentRepository;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
|
||||
import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntry;
|
||||
import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntryRepository;
|
||||
import org.apache.fineract.portfolio.dds.collection.exception.CollectionEntryNotFoundException;
|
||||
import org.apache.fineract.portfolio.dds.collection.exception.CollectionEntryNotPendingException;
|
||||
import org.apache.fineract.portfolio.dds.collection.exception.NothingToSettleException;
|
||||
import org.apache.fineract.portfolio.dds.collection.serialization.CollectionEntryCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
|
||||
import org.apache.fineract.portfolio.dds.party.exception.PartyAlreadyClosedException;
|
||||
import org.apache.fineract.portfolio.dds.party.exception.PartyNotFoundException;
|
||||
import org.apache.fineract.portfolio.dds.transaction.domain.DdsTransactionMode;
|
||||
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransaction;
|
||||
import org.apache.fineract.portfolio.dds.transaction.exception.PartyNotCollectedByAgentException;
|
||||
import org.apache.fineract.portfolio.dds.transaction.service.PartyTransactionWritePlatformService;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Writing down collections, taking them back, and turning a day's worth into real credits.
|
||||
*
|
||||
* The division of labour here is the whole point of the feature. Everything up to the settlement
|
||||
* writes rows in one table and touches nothing else - no balance, no ledger, no agent's cash. The
|
||||
* settlement is where it becomes the bank's business, and what it does there is not its own version
|
||||
* of a posting but the same one the counter has always used.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class CollectionEntryWritePlatformServiceJpaRepositoryImpl implements CollectionEntryWritePlatformService {
|
||||
|
||||
private final CollectionEntryCommandFromApiJsonDeserializer fromApiJsonDeserializer;
|
||||
private final CollectionEntryRepository collectionEntryRepository;
|
||||
private final AgentRepository agentRepository;
|
||||
private final PartyRepository partyRepository;
|
||||
private final PartyTransactionWritePlatformService partyTransactionWritePlatformService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult createEntries(final JsonCommand command) {
|
||||
this.fromApiJsonDeserializer.validateForCreate(command.json());
|
||||
|
||||
final Agent agent = agentOf(command.longValueOfParameterNamed("agentId"));
|
||||
final LocalDate entryDate = command.localDateValueOfParameterNamed("entryDate");
|
||||
|
||||
final JsonArray rows = command.arrayOfParameterNamed("entries");
|
||||
final List<Long> written = new ArrayList<>(rows.size());
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
final JsonObject row = rows.get(i).getAsJsonObject();
|
||||
final Party party = collectableParty(stringOf(row, "partyCode"), agent);
|
||||
final BigDecimal amount = row.get("amount").getAsBigDecimal();
|
||||
|
||||
final CollectionEntry entry = new CollectionEntry(agent, party, entryDate, amount,
|
||||
DdsTransactionMode.canonicalOrOriginal(stringOf(row, "transactionMode")), stringOf(row, "chequeNo"),
|
||||
stringOf(row, "tokenNo"), stringOf(row, "description"));
|
||||
|
||||
written.add(this.collectionEntryRepository.saveAndFlush(entry).getId());
|
||||
total = total.add(amount);
|
||||
}
|
||||
|
||||
final Map<String, Object> changes = new LinkedHashMap<>();
|
||||
changes.put("entriesWritten", written.size());
|
||||
changes.put("totalAmount", total);
|
||||
changes.put("entryIds", written);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(agent.getId()).with(changes).build();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult cancelEntry(final Long entryId, final JsonCommand command) {
|
||||
final CollectionEntry entry = this.collectionEntryRepository.findById(entryId)
|
||||
.orElseThrow(() -> new CollectionEntryNotFoundException(entryId));
|
||||
|
||||
if (!entry.isPending()) {
|
||||
throw new CollectionEntryNotPendingException(entryId, entry.getStatus());
|
||||
}
|
||||
|
||||
entry.markCancelled();
|
||||
this.collectionEntryRepository.saveAndFlush(entry);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(entryId).build();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult settleAgentDay(final JsonCommand command) {
|
||||
this.fromApiJsonDeserializer.validateForSettle(command.json());
|
||||
|
||||
final Agent agent = agentOf(command.longValueOfParameterNamed("agentId"));
|
||||
final LocalDate entryDate = command.localDateValueOfParameterNamed("entryDate");
|
||||
|
||||
final List<CollectionEntry> pending = this.collectionEntryRepository.findPendingFor(agent.getId(), entryDate);
|
||||
if (pending.isEmpty()) {
|
||||
throw new NothingToSettleException(agent.getAgentCode(), entryDate.toString());
|
||||
}
|
||||
|
||||
final LocalDate settledOn = DateUtils.getBusinessLocalDate();
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
|
||||
for (final CollectionEntry entry : pending) {
|
||||
// Asked again, now. An account can be closed, or moved to another agent, between the
|
||||
// round and the counter - and this is the moment the money actually lands, so this is the
|
||||
// moment the account has to be fit to receive it.
|
||||
final Party party = collectableParty(entry.getParty().getPartyCode(), agent);
|
||||
|
||||
// Posted through the counter's own code, not a copy of it. The entry is the note; what
|
||||
// this writes is an ordinary collection, indistinguishable on the ledger from one typed
|
||||
// at the branch - because that is exactly what it is.
|
||||
final PartyTransaction transaction = this.partyTransactionWritePlatformService.postSingleCollection(agent, party,
|
||||
entry.getEntryDate(), entry.getAmount(), entry.getTransactionMode(), null, null, entry.getChequeNo(),
|
||||
entry.getTokenNo(), entry.getDescription());
|
||||
|
||||
entry.markSettled(settledOn, transaction.getId());
|
||||
this.collectionEntryRepository.save(entry);
|
||||
total = total.add(entry.getAmount());
|
||||
}
|
||||
|
||||
final Map<String, Object> changes = new LinkedHashMap<>();
|
||||
changes.put("entriesSettled", pending.size());
|
||||
changes.put("totalAmount", total);
|
||||
changes.put("settledOn", settledOn);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(agent.getId()).with(changes).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* The account this line names, if this agent may collect from it at all.
|
||||
*
|
||||
* The same three questions the counter asks, asked here as well - once when the agent writes the
|
||||
* line down and again when the clerk settles it. Asking early is what lets an agent be told at
|
||||
* the customer's door rather than hours later at the branch; asking again is what stops a line
|
||||
* written this morning being posted this evening onto an account that has since been closed.
|
||||
*/
|
||||
/** Named rather than found empty-handed: an id that matches no agent is a caller's mistake, not a day with nothing in it. */
|
||||
private Agent agentOf(final Long agentId) {
|
||||
return this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
|
||||
}
|
||||
|
||||
private Party collectableParty(final String partyCode, final Agent agent) {
|
||||
final Party party = this.partyRepository.findByPartyCode(partyCode).orElseThrow(() -> new PartyNotFoundException(partyCode));
|
||||
if (party.isClosed()) {
|
||||
throw new PartyAlreadyClosedException(partyCode);
|
||||
}
|
||||
if (!party.getAgent().getId().equals(agent.getId())) {
|
||||
throw new PartyNotCollectedByAgentException(partyCode, agent.getAgentCode());
|
||||
}
|
||||
return party;
|
||||
}
|
||||
|
||||
private String stringOf(final JsonObject json, final String field) {
|
||||
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.starter;
|
||||
|
||||
import org.apache.fineract.organisation.agent.domain.AgentRepository;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
|
||||
import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntryRepository;
|
||||
import org.apache.fineract.portfolio.dds.collection.serialization.CollectionEntryCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryReadPlatformService;
|
||||
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryWritePlatformService;
|
||||
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryWritePlatformServiceJpaRepositoryImpl;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
|
||||
import org.apache.fineract.portfolio.dds.transaction.service.PartyTransactionWritePlatformService;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
@Configuration
|
||||
public class DdsCollectionConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(CollectionEntryReadPlatformService.class)
|
||||
public CollectionEntryReadPlatformService collectionEntryReadPlatformService(JdbcTemplate jdbcTemplate,
|
||||
AgentRepository agentRepository) {
|
||||
return new CollectionEntryReadPlatformServiceImpl(jdbcTemplate, agentRepository);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(CollectionEntryWritePlatformService.class)
|
||||
public CollectionEntryWritePlatformService collectionEntryWritePlatformService(
|
||||
CollectionEntryCommandFromApiJsonDeserializer fromApiJsonDeserializer, CollectionEntryRepository collectionEntryRepository,
|
||||
AgentRepository agentRepository, PartyRepository partyRepository,
|
||||
PartyTransactionWritePlatformService partyTransactionWritePlatformService) {
|
||||
return new CollectionEntryWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, collectionEntryRepository,
|
||||
agentRepository, partyRepository, partyTransactionWritePlatformService);
|
||||
}
|
||||
}
|
||||
+86
-8
@@ -31,6 +31,7 @@ import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
@@ -40,6 +41,7 @@ import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamE
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.dds.party.data.PartyData;
|
||||
import org.apache.fineract.portfolio.dds.party.service.PartyReadPlatformService;
|
||||
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/dds/parties")
|
||||
@@ -54,13 +56,53 @@ public class PartiesApiResource {
|
||||
private final PartyReadPlatformService readPlatformService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
/**
|
||||
* Called directly for the one-off deposit-account catch-up. That is a data fix rather than an
|
||||
* everyday action, so it takes no request body and does not need the command-source machinery -
|
||||
* the account it opens and the entries it writes are their own record.
|
||||
*/
|
||||
private final PartyWritePlatformService partyWritePlatformService;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve DDS Parties", description = "Returns the list of DDS parties.")
|
||||
public Collection<PartyData> retrieveAll() {
|
||||
@Operation(summary = "Retrieve DDS Parties", description = "Returns the list of DDS parties. "
|
||||
+ "Pass agentId to get only the parties a given agent collects for - what the daily "
|
||||
+ "transaction screen needs, since it works one agent at a time.")
|
||||
public Collection<PartyData> retrieveAll(
|
||||
@QueryParam("agentId") @Parameter(description = "agentId") final Long agentId) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
return readPlatformService.retrieveAll();
|
||||
return readPlatformService.retrieveAll(agentId);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("dueforclosure")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "DDS accounts due for closure", description = "The accounts whose term has run out and which are still "
|
||||
+ "open - the branch's list of closures waiting to be done, oldest maturity first.\n" + "\n"
|
||||
+ "Nothing closes itself. A matured account is money the bank now owes the customer, and where it goes has to be "
|
||||
+ "agreed with them, so it stays on this list until a clerk deals with it - in two steps, paying it out and then "
|
||||
+ "closing it. An account still carrying a balance is waiting on the payment; one showing nil has been paid and "
|
||||
+ "is waiting on the close.")
|
||||
public Collection<PartyData> retrieveDueForClosure() {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
return readPlatformService.retrieveDueForClosure();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("creditaccounts")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Accounts a DDS maturity may be paid into",
|
||||
description = "The customer's own accounts that can take a maturity payout: active, a savings account rather "
|
||||
+ "than a fixed or recurring deposit, and not one of their DDS scheme accounts.\n" + "\n"
|
||||
+ "The same conditions command=payout enforces, so a screen offering this list cannot offer a choice "
|
||||
+ "the server will then refuse. Returns id, accountNo, productName and balance.")
|
||||
public Collection<Map<String, Object>> retrieveCreditableAccounts(
|
||||
@QueryParam("clientId") @Parameter(description = "clientId") final Long clientId) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
return readPlatformService.retrieveCreditableAccounts(clientId);
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -79,11 +121,13 @@ public class PartiesApiResource {
|
||||
@Operation(summary = "Create a DDS Party", description = "Mandatory fields: clientId, agentId, subType, partyName, address1, "
|
||||
+ "city, pincode, mobileNoSms, operation, adultMinor, openingDate, dailyDepositAmount, days\n" + "\n"
|
||||
+ "Optional fields: lastAccount, nominal, aadharNo, partyName1, address2, address3, gender, panCardNo, telephone, "
|
||||
+ "birthDate, occupation, risk, ckycId, minorDate, guardianClientId, minAccountOpt, relation, month, maturityDate, "
|
||||
+ "birthDate, occupation, risk, ckycId, minorDate, guardianClientId, minAccountOpt, relation, month, "
|
||||
+ "reopenDate, depositAmount, ledgerNo, withdrawType, lien, payDate, paymentAmount, openingBalance, isSmsActive, "
|
||||
+ "isEmailActive, interestDate, spDescription, freezeCode, advCode, creditToBranch, creditToAccountType, "
|
||||
+ "creditToAccount, nomineeApplicable, nomineeSplitMode, nominees, savingsAccountNo (links this party to an existing, "
|
||||
+ "non-closed real savings account of the same client, resolved by account number - omit if the client has none)")
|
||||
+ "non-closed real savings account of the same client, resolved by account number - omit if the client has none)\n" + "\n"
|
||||
+ "maturityDate and maturityAmount are not accepted: both are worked out from openingDate, month/days, "
|
||||
+ "dailyDepositAmount and interestRate, and are returned on read.")
|
||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.createParty() //
|
||||
@@ -110,12 +154,46 @@ public class PartiesApiResource {
|
||||
@Path("{partyId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Close a DDS Party", description = "command=close is the only supported command. Mandatory field: "
|
||||
+ "closeDate. The party's balance must already be zero (settled with a full Withdraw Deposite payout) before it "
|
||||
+ "can be closed.")
|
||||
@Operation(summary = "Act on a DDS Party", description = "command=close closes the party - its balance must already be "
|
||||
+ "zero (settled with command=payout, or with a full Withdraw Deposite) and closeDate is mandatory.\n" + "\n"
|
||||
+ "command=opendepositaccount opens the real deposit account of a party created before DDS began opening them, "
|
||||
+ "and brings its existing balance onto that account. Run once per such party; refused for a party that already "
|
||||
+ "has an account. No request body.\n" + "\n"
|
||||
+ "command=payout pays a matured account out and leaves it open with a nil balance, for command=close to be run "
|
||||
+ "against separately once the clerk has seen the money land. What is paid is what the account actually holds - "
|
||||
+ "the deposits plus the interest already posted - not the figure quoted at opening. Optional field: "
|
||||
+ "toSavingsAccountNo, another open savings account of the same customer, which the money is moved into. Omit it "
|
||||
+ "when the customer has no other account here: the money is then parked in the matured-deposits payable account "
|
||||
+ "for the branch to send on to their account at another bank. Refused before the maturity date, and refused on "
|
||||
+ "an account with nothing left to pay - that one needs closing, not paying.\n" + "\n"
|
||||
+ "command=prematureclose closes an account before its term is up and pays out what is left, in one act - "
|
||||
+ "unlike payout, which leaves the account open. Mandatory field: shortfallBorneBy, either CUSTOMER (their "
|
||||
+ "interest is recalculated at the scheme's reduced rate and the difference taken back) or AGENT (the customer "
|
||||
+ "keeps a full month's interest and the agent funds the days left in it). The agent is charged a share of "
|
||||
+ "their commission either way. Optional field: toSavingsAccountNo, as for payout. The lock-in period, the "
|
||||
+ "rate reduction and the agent's share are the scheme's own and are read as the account is closed, so terms "
|
||||
+ "the bank changes take effect on accounts already open. Refused inside the lock-in period, and refused on an "
|
||||
+ "account that has already matured - that one is paid out and closed in the ordinary way.")
|
||||
public CommandProcessingResult handleCommand(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId,
|
||||
@QueryParam("command") @Parameter(description = "command") final String commandParam,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
if ("opendepositaccount".equals(commandParam)) {
|
||||
return this.partyWritePlatformService.openDepositAccount(partyId);
|
||||
}
|
||||
if ("payout".equals(commandParam)) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.payOutParty(partyId) //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
if ("prematureclose".equals(commandParam)) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.prematureCloseParty(partyId) //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
if (!"close".equals(commandParam)) {
|
||||
throw new UnrecognizedQueryParamException("command", commandParam);
|
||||
}
|
||||
|
||||
+92
-4
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.domain;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
@@ -26,17 +27,104 @@ import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface PartyRepository extends JpaRepository<Party, Long> {
|
||||
|
||||
@Query("select coalesce(max(cast(substring(p.partyCode, 3) as long)), 0) from Party p where p.partyCode like 'PT%'")
|
||||
/**
|
||||
* Native rather than JPQL: {@code cast(... as long)} reaches PostgreSQL verbatim, which knows no
|
||||
* "long" type - the cast has to name a database type.
|
||||
*/
|
||||
@Query(value = "select coalesce(max(cast(substring(party_code, 3) as bigint)), 0) from m_dds_party "
|
||||
+ "where party_code like 'PT%'", nativeQuery = true)
|
||||
Long retrieveMaxPartyCodeSuffix();
|
||||
|
||||
Optional<Party> findByPartyCode(String partyCode);
|
||||
|
||||
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo")
|
||||
/**
|
||||
* Open accounts still assigned to this agent. Nobody collects for an agent who has been switched
|
||||
* off, so these would simply stop being collected from without anyone being told.
|
||||
*/
|
||||
@Query("select count(p) from Party p where p.agent.id = :agentId and p.status = 'ACTIVE'")
|
||||
Long countActivePartiesOfAgent(@Param("agentId") Long agentId);
|
||||
|
||||
/**
|
||||
* Accounts an interest run covers. Closed accounts are excluded: a closed account has been paid
|
||||
* out and settled to zero, so accruing further interest on it would credit an account nobody can
|
||||
* draw on and put the DDS balances out of step with what was actually paid.
|
||||
*/
|
||||
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
|
||||
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType)")
|
||||
List<Party> findForInterestBatch(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
|
||||
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo);
|
||||
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType);
|
||||
|
||||
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
|
||||
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) "
|
||||
+ "and (p.od1Flag = true or p.od2Flag = true)")
|
||||
List<Party> findForOdInterestBatch(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
|
||||
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo);
|
||||
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType);
|
||||
|
||||
/**
|
||||
* Accounts holding interest that has been calculated but not yet credited - what an Interest
|
||||
* Posting run has to pay in. Accounts holding nothing are left out rather than previewed as zero
|
||||
* rows.
|
||||
*/
|
||||
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
|
||||
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) and p.interestCalc > 0")
|
||||
List<Party> findWithAccruedInterest(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
|
||||
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType);
|
||||
|
||||
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
|
||||
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) and p.odInterestCalc > 0")
|
||||
List<Party> findWithAccruedOdInterest(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
|
||||
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType);
|
||||
|
||||
/**
|
||||
* Matured accounts a Maturity Auto Close run could settle: the term is up, there is money to hand
|
||||
* back, and the customer has already said which account to hand it into.
|
||||
*
|
||||
* The destination is part of the search rather than a check made afterwards, because it is what
|
||||
* separates an account this run may touch from one it may not. Without it there is nowhere to
|
||||
* send the money, and the run's whole premise - that nobody need choose anything today, because
|
||||
* the choice was made when the account was opened - does not hold.
|
||||
*
|
||||
* Two fields carry that choice and both are asked for. An account number on its own is a note of
|
||||
* where the customer banks; the Adv Code is the instruction to use it, and only "Transfer To
|
||||
* Saving" says the matured amount goes there. Reading the number without the code would settle
|
||||
* accounts whose customer never asked for it - fourteen of the fifteen on the books say "None".
|
||||
*
|
||||
* Whether the scheme has auto-closing switched on is decided by the caller, which holds the
|
||||
* schemes; this asks only about the accounts. Ordered oldest maturity first, so the ones that
|
||||
* have waited longest are listed at the top of what the clerk reviews.
|
||||
*/
|
||||
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
|
||||
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) "
|
||||
+ "and p.maturityDate is not null and p.maturityDate <= :asOn and p.currentBalance > 0 "
|
||||
+ "and lower(p.advCode) = 'transfer to saving' "
|
||||
+ "and p.creditToAccount is not null and length(trim(p.creditToAccount)) > 0 "
|
||||
+ "order by p.maturityDate, p.id")
|
||||
List<Party> findDueForMaturityAutoClose(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
|
||||
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType,
|
||||
@Param("asOn") LocalDate asOn);
|
||||
|
||||
/**
|
||||
* Matured accounts whose interest has not been brought up to their maturity date.
|
||||
*
|
||||
* Interest reaches an account through a posting run, and a run is a monthly thing, set per
|
||||
* scheme and switchable off. A term ending between two runs therefore leaves the last stretch
|
||||
* uncredited, and a payout made before the next run hands the customer less than they earned -
|
||||
* which is what happened to every account on a scheme with automatic posting turned off.
|
||||
*
|
||||
* Maturity is the one date the money cannot wait for the next run, so these are found on their
|
||||
* own and brought up to date. Anything already posted through its maturity date is left alone.
|
||||
*/
|
||||
@Query("select p from Party p where p.status = 'ACTIVE' and p.maturityDate is not null and p.maturityDate <= :asOn "
|
||||
+ "and (p.interestDate is null or p.interestDate < p.maturityDate) order by p.maturityDate, p.id")
|
||||
List<Party> findMaturedWithInterestOutstanding(@Param("asOn") LocalDate asOn);
|
||||
|
||||
/**
|
||||
* Minors' accounts whose holder has reached the age of majority.
|
||||
*
|
||||
* Found on their birthday and not before, but turned over only once their own KYC is verified -
|
||||
* the caller checks that, because the answer lives on the bank's KYC record rather than here.
|
||||
*/
|
||||
@Query("select p from Party p where p.status = 'ACTIVE' and lower(p.adultMinor) = 'minor' "
|
||||
+ "and p.birthDate is not null and p.birthDate <= :bornOnOrBefore order by p.id")
|
||||
List<Party> findMinorsWhoHaveComeOfAge(@Param("bornOnOrBefore") LocalDate bornOnOrBefore);
|
||||
}
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a DDS party cannot be opened because no savings product has been named for DDS.
|
||||
*
|
||||
* Refusing is the point. A party created without a real account behind it is the state this whole
|
||||
* change exists to end - the customer's money would live only inside DDS again, and nobody would
|
||||
* notice until someone asked the bank's books where it was.
|
||||
*/
|
||||
public class DdsDepositProductNotConfiguredException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public DdsDepositProductNotConfiguredException() {
|
||||
super("error.msg.dds.deposit.product.not.configured",
|
||||
"No savings product is set for DDS accounts, so this customer's account cannot be opened. "
|
||||
+ "Set the 'dds-deposit-product' configuration to a savings product first.");
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a party that already has a deposit account is asked to open one.
|
||||
*
|
||||
* Two accounts for one customer is worse than the one they have: collections would start splitting
|
||||
* between them, and the opening balance would be counted twice. The catch-up is meant to be run once
|
||||
* per party, and running it twice is the mistake this catches.
|
||||
*/
|
||||
public class PartyAccountAlreadyOpenException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyAccountAlreadyOpenException(final String partyCode) {
|
||||
super("error.msg.dds.party.account.already.open",
|
||||
"Party " + partyCode + " already has a deposit account, so another one cannot be opened for it.", partyCode);
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when an account that has already reached its term is put through an early closure.
|
||||
*
|
||||
* Nothing was closed early, so there is no shortfall to settle: the customer saved for the whole term
|
||||
* and has earned the interest in full. Charging for an early closure here would take money they are
|
||||
* owed, which is why this is refused rather than quietly charging nothing - the clerk has picked the
|
||||
* wrong action, and should pay the account out and close it in the ordinary way.
|
||||
*/
|
||||
public class PartyAlreadyMaturedException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyAlreadyMaturedException(final String partyCode, final LocalDate maturityDate) {
|
||||
super("error.msg.dds.party.already.matured",
|
||||
"Account " + partyCode + " matured on " + maturityDate
|
||||
+ ", so it cannot be closed early. Pay it out and close it in the ordinary way.",
|
||||
partyCode, maturityDate);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a party's maturity proceeds are pointed at an account that cannot receive them.
|
||||
*
|
||||
* The whole matured amount goes to this account, years after somebody typed it. Refusing it now puts
|
||||
* the problem in front of the officer who can still ask the customer; the alternative is finding out
|
||||
* on the maturity date, with the money owed and nowhere to send it.
|
||||
*/
|
||||
public class PartyCreditAccountUnusableException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyCreditAccountUnusableException(final String accountNo, final String reason) {
|
||||
super("error.msg.dds.party.credit.account.unusable", "The maturity proceeds cannot be credited to account " + accountNo + ": "
|
||||
+ reason + ". Pick one of this customer's own open savings accounts.", accountNo, reason);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a maturity payout is attempted on a party that has no real deposit account behind it.
|
||||
*
|
||||
* There is nowhere to pay the money out of. Parties opened before DDS began opening real accounts
|
||||
* are in this state; the deposit-account catch-up brings them onto the books, and only then can they
|
||||
* be paid out and closed.
|
||||
*/
|
||||
public class PartyHasNoDepositAccountException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyHasNoDepositAccountException(final String partyCode) {
|
||||
super("error.msg.dds.party.has.no.deposit.account", "Account " + partyCode
|
||||
+ " has no deposit account behind it, so there is nothing to pay out from. Open its deposit account first.", partyCode);
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a matured account is put through a payout while money on it is pledged against a loan.
|
||||
*
|
||||
* A customer who needs cash before their term ends can borrow against the deposit instead of breaking
|
||||
* it: the scheme carries on, and the amount lent is marked as a lien so it cannot leave. Paying the
|
||||
* deposit out while that stands would hand the customer money the bank is holding as its security and
|
||||
* leave the loan with nothing behind it.
|
||||
*
|
||||
* So maturity waits on the loan. The term has ended and the account is owed, but it is not paid until
|
||||
* the borrowing against it is cleared and the lien released.
|
||||
*/
|
||||
public class PartyHeldUnderLienException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyHeldUnderLienException(final String partyCode, final BigDecimal heldAmount) {
|
||||
super("error.msg.dds.party.held.under.lien",
|
||||
"Account " + partyCode + " has " + inRupees(heldAmount) + " held under lien against a loan. "
|
||||
+ "The loan must be repaid and the lien released before this deposit can be paid out.",
|
||||
partyCode, heldAmount);
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole account is held, by the lien marked on the party itself.
|
||||
*
|
||||
* No amount, because there is none to state: DDS pays a deposit out whole or not at all, so a
|
||||
* lien on part of one would hold nothing the customer could otherwise have reached. The reason
|
||||
* takes its place, and is what the clerk needs - "held" they can already see, "held for what"
|
||||
* is the thing nobody could answer before.
|
||||
*/
|
||||
public PartyHeldUnderLienException(final String partyCode, final String reason) {
|
||||
super("error.msg.dds.party.held.under.lien",
|
||||
"Account " + partyCode + " is under lien" + because(reason)
|
||||
+ ". The lien must be released before money can leave this account.",
|
||||
partyCode, reason);
|
||||
}
|
||||
|
||||
private static String because(final String reason) {
|
||||
return reason == null || reason.isBlank() ? "" : " (" + reason.trim() + ")";
|
||||
}
|
||||
|
||||
/**
|
||||
* Balances are carried to six places, which is right for arithmetic and wrong for a sentence - a
|
||||
* clerk reading "500.000000" sees a system message, not an amount of money.
|
||||
*/
|
||||
private static String inRupees(final BigDecimal amount) {
|
||||
return amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.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);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.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);
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when 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);
|
||||
}
|
||||
}
|
||||
+44
@@ -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);
|
||||
}
|
||||
}
|
||||
+52
@@ -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);
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.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);
|
||||
}
|
||||
}
|
||||
+351
-10
@@ -22,18 +22,24 @@ 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;
|
||||
|
||||
@@ -43,14 +49,62 @@ public final class PartyCommandFromApiJsonDeserializer {
|
||||
private static final Set<String> 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",
|
||||
"minorDate", "guardianClientId", "minAccountOpt", "relation", "openingDate", "month", "maturityDate", "reopenDate",
|
||||
// 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", "dateFormat", "locale"));
|
||||
"savingsAccountNo", "officeId", "lienReason", "dateFormat", "locale"));
|
||||
|
||||
private static final Set<String> SUPPORTED_NOMINEE_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList("nominationDate", "name", "mobileNo", "birthDate", "minor", "relation", "gender", "guardianName"));
|
||||
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;
|
||||
|
||||
@@ -78,6 +132,11 @@ public final class PartyCommandFromApiJsonDeserializer {
|
||||
.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()
|
||||
@@ -94,13 +153,21 @@ public final class PartyCommandFromApiJsonDeserializer {
|
||||
.notExceedingLengthOf(20);
|
||||
v.reset().parameter("adultMinor").value(this.fromApiJsonHelper.extractStringNamed("adultMinor", element)).notBlank()
|
||||
.notExceedingLengthOf(20);
|
||||
v.reset().parameter("openingDate").value(this.fromApiJsonHelper.extractLocalDateNamed("openingDate", element)).notNull();
|
||||
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();
|
||||
v.reset().parameter("days").value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("days", element)).notNull()
|
||||
.integerGreaterThanZero();
|
||||
validateTermLength(element, v);
|
||||
|
||||
validateOptionalFields(element, v);
|
||||
validateIdentifierFormats(element, v);
|
||||
validateLien(element, v);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists("nominees", element)) {
|
||||
validateNominees(element.getAsJsonObject(), dataValidationErrors);
|
||||
@@ -178,6 +245,8 @@ public final class PartyCommandFromApiJsonDeserializer {
|
||||
}
|
||||
|
||||
validateOptionalFields(element, v);
|
||||
validateIdentifierFormats(element, v);
|
||||
validateLien(element, v);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists("nominees", element)) {
|
||||
validateNominees(element.getAsJsonObject(), dataValidationErrors);
|
||||
@@ -186,6 +255,69 @@ public final class PartyCommandFromApiJsonDeserializer {
|
||||
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.
|
||||
*/
|
||||
public void validateForPrematureClose(final String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
throw new InvalidJsonException();
|
||||
}
|
||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
|
||||
|
||||
}.getType();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json,
|
||||
Set.of("shortfallBorneBy", "toSavingsAccountNo", "closeDate", "dateFormat", "locale"));
|
||||
|
||||
final List<ApiParameterError> 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);
|
||||
}
|
||||
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
}
|
||||
|
||||
public void validateForPayout(final String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
return;
|
||||
}
|
||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
|
||||
|
||||
}.getType();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, Set.of("toSavingsAccountNo", "dateFormat", "locale"));
|
||||
|
||||
final List<ApiParameterError> 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();
|
||||
@@ -205,6 +337,50 @@ public final class PartyCommandFromApiJsonDeserializer {
|
||||
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);
|
||||
@@ -229,6 +405,7 @@ public final class PartyCommandFromApiJsonDeserializer {
|
||||
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))
|
||||
@@ -265,19 +442,156 @@ public final class PartyCommandFromApiJsonDeserializer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ApiParameterError> 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<Integer> 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().parameterAtIndexArray("nominationDate", i).value(extractString(nomineeJson, "nominationDate")).notBlank();
|
||||
v.reset().parameterAtIndexArray("name", i).value(extractString(nomineeJson, "name")).notBlank().notExceedingLengthOf(100);
|
||||
v.reset().parameterAtIndexArray("relation", i).value(extractString(nomineeJson, "relation")).notBlank()
|
||||
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().parameterAtIndexArray("gender", i).value(extractString(nomineeJson, "gender")).notBlank()
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,10 +599,37 @@ public final class PartyCommandFromApiJsonDeserializer {
|
||||
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<ApiParameterError> 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+29
-1
@@ -19,11 +19,39 @@
|
||||
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 {
|
||||
|
||||
Collection<PartyData> retrieveAll();
|
||||
/**
|
||||
* 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<Map<String, Object>> retrieveCreditableAccounts(Long clientId);
|
||||
|
||||
/**
|
||||
* @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<PartyData> 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<PartyData> retrieveDueForClosure();
|
||||
}
|
||||
|
||||
+136
-10
@@ -18,6 +18,8 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
@@ -27,6 +29,7 @@ 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.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;
|
||||
@@ -46,39 +49,104 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
|
||||
|
||||
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.savings_account_id as savingsAccountId, "
|
||||
+ " p.sub_type as subType, p.party_code as partyCode, p.last_account as lastAccount, p.nominal as nominal, "
|
||||
+ " 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.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.sp_description as spDescription, "
|
||||
+ " 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 "
|
||||
+ " 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: 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")) //
|
||||
.lastAccount(rs.getString("lastAccount")) //
|
||||
.nominal(rs.getBoolean("nominal")) //
|
||||
@@ -101,6 +169,8 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
|
||||
.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")) //
|
||||
@@ -118,6 +188,7 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
|
||||
.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")) //
|
||||
@@ -125,6 +196,8 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
|
||||
.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")) //
|
||||
@@ -146,7 +219,8 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
|
||||
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 from m_dds_party_nominee n";
|
||||
+ " 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
|
||||
@@ -154,18 +228,43 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
|
||||
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"));
|
||||
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<PartyData> retrieveAll() {
|
||||
final String sql = "select " + PARTY_MAPPER.schema() + " order by p.id";
|
||||
final List<PartyData> parties = this.jdbcTemplate.query(sql, PARTY_MAPPER); // NOSONAR
|
||||
public Collection<PartyData> retrieveAll(final Long agentId) {
|
||||
final List<PartyData> 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<PartyData> retrieveDueForClosure() {
|
||||
final String sql = "select " + PARTY_MAPPER.schema()
|
||||
+ " where p.status = 'ACTIVE' and p.maturity_date is not null and p.maturity_date <= current_date"
|
||||
+ " order by p.maturity_date, p.party_code";
|
||||
return attachNominees(this.jdbcTemplate.query(sql, PARTY_MAPPER)); // NOSONAR
|
||||
}
|
||||
|
||||
@Override
|
||||
public PartyData retrieveOne(final Long partyId) {
|
||||
try {
|
||||
@@ -182,7 +281,10 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
|
||||
return parties;
|
||||
}
|
||||
|
||||
final String sql = "select " + NOMINEE_MAPPER.schema() + " order by n.id";
|
||||
// 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<Map.Entry<Long, PartyNomineeData>> rows = this.jdbcTemplate.query(sql, NOMINEE_MAPPER); // NOSONAR
|
||||
|
||||
final Map<Long, List<PartyNomineeData>> nomineesByPartyId = new LinkedHashMap<>();
|
||||
@@ -196,4 +298,28 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
|
||||
}
|
||||
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.
|
||||
private static final String CREDITABLE_ACCOUNTS_SQL = "select a.id as id, a.account_no as \"accountNo\", "
|
||||
+ "p.name as \"productName\", a.account_balance_derived as balance 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) "
|
||||
+ "order by a.account_no";
|
||||
|
||||
@Override
|
||||
public Collection<Map<String, Object>> retrieveCreditableAccounts(final Long clientId) {
|
||||
return this.jdbcTemplate.queryForList(CREDITABLE_ACCOUNTS_SQL, clientId); // NOSONAR
|
||||
}
|
||||
}
|
||||
|
||||
+62
@@ -28,4 +28,66 @@ public interface PartyWritePlatformService {
|
||||
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:
|
||||
*
|
||||
* <pre>
|
||||
* 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
|
||||
* </pre>
|
||||
*
|
||||
* 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);
|
||||
}
|
||||
|
||||
+746
-8
@@ -22,7 +22,12 @@ import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import jakarta.persistence.PersistenceException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.Period;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -32,23 +37,49 @@ 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.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.PartyHasNoDepositAccountException;
|
||||
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.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;
|
||||
|
||||
@@ -61,6 +92,22 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
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
|
||||
@@ -76,10 +123,15 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
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, partyCode, command);
|
||||
final Party party = Party.fromJson(client, agent, officeNamed(command.longValueOfParameterNamed(OFFICE_ID)), partyCode,
|
||||
command);
|
||||
|
||||
linkSavingsAccountIfProvided(party, clientId, command);
|
||||
openDepositAccountIfUnlinked(party, client);
|
||||
addNomineesFromCommand(party, command);
|
||||
|
||||
this.partyRepository.saveAndFlush(party);
|
||||
@@ -106,8 +158,22 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
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<String, Object> 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"));
|
||||
}
|
||||
|
||||
if (changesOnly.containsKey("clientId")) {
|
||||
final Long newClientId = (Long) changesOnly.get("clientId");
|
||||
final Client newClient = this.clientRepositoryWrapper.findOneWithNotFoundDetection(newClientId);
|
||||
@@ -121,6 +187,14 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
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);
|
||||
@@ -186,6 +260,429 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
*
|
||||
* <p>
|
||||
* Two destinations, and the difference is whether the money leaves the bank:
|
||||
*
|
||||
* <pre>
|
||||
* toSavingsAccountNo another account of theirs here - moved across, nothing leaves
|
||||
* no account given parked in Matured Deposits Payable, for the branch to send on
|
||||
* </pre>
|
||||
*
|
||||
* <p>
|
||||
* 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);
|
||||
settleMaturity(party, command.stringValueOfParameterNamedAllowingNull("toSavingsAccountNo"));
|
||||
|
||||
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());
|
||||
}
|
||||
if (party.getSavingsAccountId() == null) {
|
||||
throw new PartyHasNoDepositAccountException(party.getPartyCode());
|
||||
}
|
||||
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) {
|
||||
// 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();
|
||||
if (toOwnAccount) {
|
||||
final Long toSavingsId = payableAccountId(party.clientId(), toAccountNo);
|
||||
this.ddsLedger.payOutToOwnAccount(party.getSavingsAccountId(), toSavingsId, payoutDate, amount, description);
|
||||
} else {
|
||||
this.ddsLedger.payOutToPayable(party.getSavingsAccountId(), payoutDate, amount, description);
|
||||
}
|
||||
|
||||
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 matured-deposits payable", 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");
|
||||
}
|
||||
|
||||
settleMaturity(party, toAccountNo);
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
/** 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 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.
|
||||
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 Long payableAccountId(final Long ownerClientId, final String accountNo) {
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(CREDIT_ACCOUNT_SQL, accountNo); // NOSONAR
|
||||
if (rows.isEmpty()) {
|
||||
throw new PartyCreditAccountUnusableException(accountNo, "no account with this number exists");
|
||||
}
|
||||
|
||||
final Map<String, Object> 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<Long> 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 ((Number) account.get("id")).longValue();
|
||||
}
|
||||
|
||||
/** 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<Long> productIds = this.jdbcTemplate.queryForList(DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR
|
||||
if (productIds.isEmpty() || productIds.get(0) == null) {
|
||||
throw new DdsDepositProductNotConfiguredException();
|
||||
}
|
||||
|
||||
final SavingsAccountDataDTO accountDetails = new SavingsAccountDataDTO(client, null, productIds.get(0), party.getOpeningDate(),
|
||||
this.context.authenticatedUser(), DateUtils.DEFAULT_DATE_FORMATTER);
|
||||
final CommandProcessingResult result = this.savingsApplicationProcessWritePlatformService.createActiveApplication(accountDetails);
|
||||
final Long savingsAccountId = result.getSavingsId() != null ? result.getSavingsId() : result.getResourceId();
|
||||
party.linkSavingsAccount(savingsAccountId);
|
||||
|
||||
// Withdrawals are blocked from the moment the account exists. A scheme account is a savings
|
||||
// account like any other, and left open a customer could empty a twelve-month scheme at the
|
||||
// counter the next day - which is the one thing daily deposit saving exists to prevent.
|
||||
// Deposits are untouched; only the way out is closed, and DDS lifts it for its own payouts.
|
||||
this.ddsLedger.lockDebits(savingsAccountId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -194,25 +691,266 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
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 = LocalDate.parse(nomineeJson.get("nominationDate").getAsString());
|
||||
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 = nomineeJson.has("birthDate") && !nomineeJson.get("birthDate").isJsonNull()
|
||||
? LocalDate.parse(nomineeJson.get("birthDate").getAsString())
|
||||
: null;
|
||||
final boolean minor = nomineeJson.has("minor") && !nomineeJson.get("minor").isJsonNull() && nomineeJson.get("minor")
|
||||
.getAsBoolean();
|
||||
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, minor, relation, gender, guardianName));
|
||||
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());
|
||||
|
||||
final Party party = this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId));
|
||||
|
||||
if (party.isClosed()) {
|
||||
throw new PartyAlreadyClosedException(party.getPartyCode());
|
||||
}
|
||||
if (party.getSavingsAccountId() == null) {
|
||||
throw new PartyHasNoDepositAccountException(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);
|
||||
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");
|
||||
|
||||
// Interest first, at the rate the account has been running on. Whatever is charged below is
|
||||
// then charged against a complete figure rather than one that stops at the last batch run,
|
||||
// and the passbook shows the interest earned and the part given back as two separate lines.
|
||||
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);
|
||||
|
||||
party.close(closeDate);
|
||||
this.partyRepository.saveAndFlush(party);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(partyId) //
|
||||
.with(Map.of(SHORTFALL_BORNE_BY, customerBears ? CUSTOMER_BEARS : AGENT_BEARS, //
|
||||
"shortfallAmount", borneAmount, //
|
||||
"agentCommissionPenalty", commissionPenalty)) //
|
||||
.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.getSavingsAccountId(), 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.getSavingsAccountId(), 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 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();
|
||||
if (toOwnAccount) {
|
||||
final Long toSavingsId = payableAccountId(party.clientId(), toAccountNo);
|
||||
this.ddsLedger.payOutToOwnAccount(party.getSavingsAccountId(), toSavingsId, closeDate, amount, description);
|
||||
} else {
|
||||
this.ddsLedger.payOutToPayable(party.getSavingsAccountId(), closeDate, amount, description);
|
||||
}
|
||||
|
||||
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,
|
||||
toOwnAccount ? "Early closure paid into " + toAccountNo : "Early closure parked in matured-deposits payable",
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-2
@@ -18,15 +18,23 @@
|
||||
*/
|
||||
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;
|
||||
@@ -45,8 +53,14 @@ public class DdsPartyConfiguration {
|
||||
@ConditionalOnMissingBean(PartyWritePlatformService.class)
|
||||
public PartyWritePlatformService partyWritePlatformService(PartyCommandFromApiJsonDeserializer fromApiJsonDeserializer,
|
||||
PartyRepository partyRepository, AgentRepository agentRepository, ClientRepositoryWrapper clientRepositoryWrapper,
|
||||
SavingsAccountRepository savingsAccountRepository) {
|
||||
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);
|
||||
clientRepositoryWrapper, savingsAccountRepository, ddsAccountTypeRepository,
|
||||
savingsApplicationProcessWritePlatformService, ddsLedger, partyTransactionRepository, batchRunWritePlatformService,
|
||||
context, jdbcTemplate, fromApiJsonHelper, officeRepositoryWrapper);
|
||||
}
|
||||
}
|
||||
|
||||
+38
-2
@@ -30,7 +30,12 @@ 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;
|
||||
@@ -66,9 +71,40 @@ public class DdsReportsApiResource {
|
||||
@DefaultValue("9223372036854775807") @QueryParam("partyTo") @Parameter(description = "partyTo") final Long partyTo) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
|
||||
final LocalDate from = fromDate != null ? LocalDate.parse(fromDate) : LocalDate.of(1900, 1, 1);
|
||||
final LocalDate to = toDate != null ? LocalDate.parse(toDate) : LocalDate.of(9999, 12, 31);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
+270
-76
@@ -20,6 +20,8 @@ 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;
|
||||
@@ -40,13 +42,28 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
|
||||
|
||||
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' " + "else t.transaction_type end";
|
||||
|
||||
/** 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", "PARTY_BALANCE" -> partyList(agentFrom, agentTo, partyFrom, partyTo);
|
||||
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);
|
||||
@@ -67,186 +84,326 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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\", "
|
||||
+ "a.agent_current_balance as \"Current Balance\" from m_dds_agent a order by a.agent_code";
|
||||
+ 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\", "
|
||||
+ "a.agent_current_balance as \"Cash In Hand\", a.commission as \"Commission Due\" "
|
||||
+ "from m_dds_agent a order by a.agent_code";
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql);
|
||||
return available("AGENT_CHECK_LIST", List.of("Agent Code", "Agent Name", "City", "Current Balance"), rows);
|
||||
return available("AGENT_CHECK_LIST",
|
||||
List.of("Agent Code", "Agent Name", "City", "Status", "Opening Date", "Accounts", "Cash In Hand", "Commission Due"),
|
||||
rows);
|
||||
}
|
||||
|
||||
private DdsReportData partyList(final Long agentFrom, final Long agentTo, final Long partyFrom, final Long partyTo) {
|
||||
/**
|
||||
* 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\", p.current_balance as \"Current Balance\", "
|
||||
+ "p.opening_date as \"Opening Date\" from m_dds_party p join m_dds_agent a on a.id = p.agent_id "
|
||||
+ "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<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, agentFrom, agentTo, partyFrom, partyTo);
|
||||
return available("PARTY_CHECK_LIST", List.of("Party Code", "Party Name", "Agent Code", "Current Balance", "Opening Date"), rows);
|
||||
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<Map<String, Object>> 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 \"Name\", "
|
||||
+ "a.agent_current_balance as \"Current Balance\" from m_dds_agent a where a.id between ? and ? "
|
||||
+ "order by a.agent_code";
|
||||
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", " + AGENT_STATUS_LABEL
|
||||
+ " as \"Status\", a.agent_current_balance 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<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, agentFrom, agentTo);
|
||||
return available("AGENT_WISE_BALANCE", List.of("Agent Code", "Name", "Current Balance"), rows);
|
||||
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 \"Name\", "
|
||||
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<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo);
|
||||
return available("AGENT_WISE_MONTHLY_COLLECTION", List.of("Agent Code", "Name", "Total Collection"), rows);
|
||||
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 \"Name\", b.job_date as \"Job Date\", "
|
||||
+ "b.posted_on_date as \"Posted On\", r.amount as \"Commission Amount\" from m_dds_batch_run_row r "
|
||||
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<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo);
|
||||
return available("COMMISSION_REGISTER", List.of("Agent Code", "Name", "Job Date", "Posted On", "Commission Amount"), rows);
|
||||
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) {
|
||||
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", "
|
||||
+ "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";
|
||||
+ "coalesce(sum(case when t.transaction_type = 'PARTY_TRAN' and t.direction = 'CREDIT' then t.amount end), 0) "
|
||||
+ "as \"Collected\", "
|
||||
+ "coalesce(sum(case when t.transaction_type = 'AGENT_TRAN' and t.direction = 'DEBIT' then t.amount end), 0) "
|
||||
+ "as \"Settled To Branch\", " + "a.agent_current_balance as \"Cash In Hand\", "
|
||||
+ "coalesce(sum(case when t.transaction_type = 'COMMISSION_POST' then t.amount end), 0) as \"Commission Paid\" "
|
||||
+ "from m_dds_agent a "
|
||||
+ "left join m_dds_party_transaction t on t.agent_id = a.id and t.transaction_date between ? and ? "
|
||||
+ "where a.id between ? and ? "
|
||||
+ "group by a.id, a.agent_code, a.agent_name, a.agent_current_balance order by a.agent_code";
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo);
|
||||
return available("COLLECTION_SUMMARY_AGENT_WISE", List.of("Agent Code", "Agent Name", "Total Collection"), rows);
|
||||
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) {
|
||||
final String sql = "select a.agent_code as \"Agent Code\", p.party_code as \"Party Code\", "
|
||||
+ "t.transaction_date as \"Transaction Date\", t.amount as \"Amount\", "
|
||||
// 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<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
|
||||
partyTo);
|
||||
return available("AGENT_PARTYWISE_DAILY_COLLECTION",
|
||||
List.of("Agent Code", "Party Code", "Transaction Date", "Amount", "Balance After"), rows);
|
||||
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.
|
||||
final String sql = "select t.transaction_date as \"Transaction Date\", a.agent_code as \"Agent Code\", "
|
||||
+ "coalesce(sum(t.amount), 0) as \"Total Collection\", count(*) as \"Transaction Count\" "
|
||||
+ "a.agent_name as \"Agent Name\", "
|
||||
+ "count(*) filter (where t.transaction_type = 'PARTY_TRAN' and t.direction = 'CREDIT') as \"Entries\", "
|
||||
+ "coalesce(sum(t.amount) filter (where t.transaction_type = 'PARTY_TRAN' and t.direction = 'CREDIT'), 0) "
|
||||
+ "as \"Collected\", "
|
||||
+ "coalesce(sum(t.amount) filter (where t.transaction_type = 'AGENT_TRAN' and t.direction = 'DEBIT'), 0) "
|
||||
+ "as \"Settled To Branch\" "
|
||||
+ "from m_dds_party_transaction t 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 ? group by t.transaction_date, a.id, a.agent_code "
|
||||
+ "where t.transaction_type in ('PARTY_TRAN', 'AGENT_TRAN') and t.transaction_date between ? and ? "
|
||||
+ "and a.id between ? and ? group by t.transaction_date, a.id, a.agent_code, a.agent_name "
|
||||
+ "order by t.transaction_date, a.agent_code";
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo);
|
||||
return available("DAILY_COLLECTION_REPORT",
|
||||
List.of("Transaction Date", "Agent Code", "Total Collection", "Transaction Count"), rows);
|
||||
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\", t.transaction_date as \"Transaction Date\", "
|
||||
+ "t.transaction_type as \"Transaction Type\", 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 "
|
||||
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\", t.amount 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 "
|
||||
+ "where t.transaction_type in ('INTEREST_POST', 'OD_INTEREST_POST') and t.transaction_date between ? and ? "
|
||||
+ "and p.id between ? and ? order by t.transaction_date, p.party_code";
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, partyFrom, partyTo);
|
||||
return available("PARTY_INTEREST_REGISTER",
|
||||
List.of("Party Code", "Transaction Date", "Transaction Type", "Amount", "Balance After"), rows);
|
||||
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 Integer totalAgents = this.jdbcTemplate.queryForObject("select count(*) from m_dds_agent where id between ? and ?",
|
||||
Integer.class, agentFrom, agentTo);
|
||||
final Integer totalParties = this.jdbcTemplate.queryForObject(
|
||||
"select count(*) from m_dds_party where agent_id between ? and ? and id between ? and ?", Integer.class, agentFrom,
|
||||
agentTo, partyFrom, partyTo);
|
||||
final BigDecimal totalBalance = this.jdbcTemplate.queryForObject(
|
||||
"select coalesce(sum(current_balance), 0) from m_dds_party where agent_id between ? and ? and id between ? and ?",
|
||||
BigDecimal.class, agentFrom, agentTo, partyFrom, 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_current_balance), 0) as \"Cash With Agents\", "
|
||||
+ "coalesce(sum(commission), 0) as \"Commission Due To Agents\" from m_dds_agent where id between ? and ?";
|
||||
final Map<String, Object> agentTotals = this.jdbcTemplate.queryForMap(agentSql, agentFrom, agentTo);
|
||||
|
||||
final Map<String, Object> row = Map.of("Total Agents", totalAgents, "Total Parties", totalParties, "Total Party Balance",
|
||||
totalBalance);
|
||||
return available("BALANCE_SUMMARY", List.of("Total Agents", "Total Parties", "Total Party Balance"), List.of(row));
|
||||
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<String, Object> partyTotals = this.jdbcTemplate.queryForMap(partySql, agentFrom, agentTo, partyFrom, partyTo);
|
||||
|
||||
final Map<String, Object> 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) {
|
||||
final String sql = "select p.party_code as \"Party Code\", a.agent_code as \"Agent Code\", "
|
||||
+ "t.transaction_date as \"Transaction Date\", t.transaction_type as \"Transaction Type\", "
|
||||
+ "t.direction as \"Direction\", t.amount as \"Amount\", t.description as \"Description\" "
|
||||
+ "from m_dds_party_transaction t join m_dds_party p on p.id = t.party_id "
|
||||
// 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 p.id between ? and ? "
|
||||
+ "order by t.transaction_date, t.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<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
|
||||
partyTo);
|
||||
return available("TRANSACTION_REGISTER",
|
||||
List.of("Party Code", "Agent Code", "Transaction Date", "Transaction Type", "Direction", "Amount", "Description"), rows);
|
||||
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) {
|
||||
final String sql = "select p.party_code as \"Party Code\", p.maturity_date as \"Maturity Date\", "
|
||||
+ "p.current_balance as \"Current Balance\" from m_dds_party p "
|
||||
// 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 "
|
||||
+ "where 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";
|
||||
+ "and p.id between ? and ? order by p.maturity_date, p.party_code";
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
|
||||
partyTo);
|
||||
return available("MATURE_DATE_WISE", List.of("Party Code", "Maturity Date", "Current Balance"), rows);
|
||||
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.opening_date as \"Opening Date\", p.daily_deposit_amount as \"Daily Deposit Amount\", "
|
||||
+ "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 %\", "
|
||||
+ "p.current_balance as \"Current Balance\", coalesce((select sum(t.amount) from m_dds_party_transaction t "
|
||||
+ "where t.party_id = p.id and t.transaction_type in ('INTEREST_POST', 'OD_INTEREST_POST') "
|
||||
+ "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<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
|
||||
partyTo);
|
||||
return available("INTEREST_CERTIFICATE",
|
||||
List.of("Party Code", "Party Name", "Opening Date", "Daily Deposit Amount", "Current Balance", "Interest Earned"), rows);
|
||||
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
|
||||
* deposit/advance/deduction fields tracked directly on the agent. "Deduct Commission" and
|
||||
* "PC Diff" have no corresponding data anywhere in this schema yet, so they report 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.
|
||||
* 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.total, 0) as \"Commission\", 0 as \"Deduct Commission\", "
|
||||
+ "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) - a.agent_deposit - a.festival_advance - a.other_deduction) as \"Total\", "
|
||||
+ "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.amount) as total from m_dds_batch_run_row r "
|
||||
+ "join m_dds_batch_run b on b.id = r.batch_run_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<Map<String, Object>> 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", "Deduct Commission", "Agent Deposit",
|
||||
"Festival Advance", "Other Deduction", "Total", "PC Diff"),
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -257,7 +414,10 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
|
||||
*/
|
||||
private DdsReportData supplementaryDds(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom, final Long agentTo,
|
||||
final Long partyFrom, final Long partyTo) {
|
||||
final String sql = "select a.agent_code as \"Agent Code\", p.party_code as \"Party Code\", p.party_name as \"Party Name\", "
|
||||
// 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 "
|
||||
@@ -265,8 +425,8 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
|
||||
+ "and a.id between ? and ? and p.id between ? and ? order by t.transaction_date, a.agent_code, p.party_code";
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
|
||||
partyTo);
|
||||
return available("SUPPLEMENTARY_DDS",
|
||||
List.of("Agent Code", "Party Code", "Party Name", "Transaction Date", "Mode", "Amount", "Balance After"), rows);
|
||||
return available("SUPPLEMENTARY_DDS", List.of("Agent Code", "Agent Name", "Party Code", "Party Name", "Transaction Date",
|
||||
"Mode", "Amount", "Balance After"), rows);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,6 +468,40 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
|
||||
}
|
||||
|
||||
private DdsReportData available(final String reportKey, final List<String> columns, final List<Map<String, Object>> rows) {
|
||||
return DdsReportData.builder().reportKey(reportKey).available(true).columns(columns).rows(rows).build();
|
||||
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<Map<String, Object>> normaliseDates(final List<Map<String, Object>> rows) {
|
||||
final List<Map<String, Object>> converted = new ArrayList<>(rows.size());
|
||||
for (final Map<String, Object> 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<String, Object> copy = new LinkedHashMap<>(row.size());
|
||||
for (final Map.Entry<String, Object> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -20,6 +20,8 @@ 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;
|
||||
@@ -30,4 +32,28 @@ public interface PartyTransactionRepository extends JpaRepository<PartyTransacti
|
||||
+ "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<PartyTransaction> findForPartiesUpTo(@Param("partyIds") Collection<Long> partyIds, @Param("toDate") LocalDate toDate);
|
||||
}
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.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);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.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);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.transaction.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when 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);
|
||||
}
|
||||
}
|
||||
+85
-12
@@ -34,6 +34,8 @@ 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;
|
||||
|
||||
@@ -48,15 +50,29 @@ import org.springframework.stereotype.Component;
|
||||
@Component
|
||||
public final class PartyTransactionCommandFromApiJsonDeserializer {
|
||||
|
||||
private static final Set<String> SUPPORTED_TRANSACTION_TYPES = new HashSet<>(
|
||||
Arrays.asList("PARTY_TRAN", "WITHDRAW_DEPOSITE", "AGENT_TRAN"));
|
||||
/**
|
||||
* 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<String> SUPPORTED_TRANSACTION_TYPES = List.of("PARTY_TRAN", "AGENT_TRAN", "WITHDRAW_DEPOSITE");
|
||||
|
||||
private static final Set<String> 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<String> SUPPORTED_COLLECTION_PARAMETERS = new HashSet<>(Arrays.asList("partyCode", "transactionMode",
|
||||
"accountType", "accountNo", "amount", "chequeNo", "tokenNo", "description"));
|
||||
"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;
|
||||
|
||||
@@ -84,25 +100,68 @@ public final class PartyTransactionCommandFromApiJsonDeserializer {
|
||||
.integerGreaterThanZero();
|
||||
|
||||
final String transactionType = this.fromApiJsonHelper.extractStringNamed("transactionType", element);
|
||||
v.reset().parameter("transactionType").value(transactionType).notBlank().isOneOfTheseStringValues(
|
||||
SUPPORTED_TRANSACTION_TYPES.toArray());
|
||||
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();
|
||||
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();
|
||||
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().parameterAtIndexArray("partyCode", i).value(extractString(row, "partyCode")).notBlank();
|
||||
v.reset().parameterAtIndexArray("accountNo", i).value(extractString(row, "accountNo")).notBlank();
|
||||
v.reset().parameterAtIndexArray("amount", i).value(extractBigDecimal(row, "amount")).notNull().positiveAmount();
|
||||
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)) {
|
||||
@@ -115,7 +174,17 @@ public final class PartyTransactionCommandFromApiJsonDeserializer {
|
||||
} 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);
|
||||
}
|
||||
@@ -128,6 +197,10 @@ public final class PartyTransactionCommandFromApiJsonDeserializer {
|
||||
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<ApiParameterError> dataValidationErrors) {
|
||||
if (!dataValidationErrors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
|
||||
|
||||
+21
@@ -18,10 +18,31 @@
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
+200
-18
@@ -33,17 +33,29 @@ 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.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.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;
|
||||
@@ -58,6 +70,12 @@ public class PartyTransactionWritePlatformServiceJpaRepositoryImpl implements Pa
|
||||
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) {
|
||||
@@ -70,7 +88,9 @@ public class PartyTransactionWritePlatformServiceJpaRepositoryImpl implements Pa
|
||||
.orElseThrow(() -> new AgentNotFoundException(agentId));
|
||||
|
||||
final String transactionType = command.stringValueOfParameterNamed("transactionType");
|
||||
final LocalDate transactionDate = command.localDateValueOfParameterNamed("transactionDate");
|
||||
// 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<PartyTransaction> posted = new ArrayList<>();
|
||||
if ("PARTY_TRAN".equals(transactionType)) {
|
||||
@@ -97,28 +117,152 @@ public class PartyTransactionWritePlatformServiceJpaRepositoryImpl implements Pa
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<PartyTransaction> postCollections(final Agent agent, final LocalDate transactionDate, final JsonCommand command) {
|
||||
final List<PartyTransaction> 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 = this.partyRepository.findByPartyCode(partyCode)
|
||||
.orElseThrow(() -> new PartyNotFoundException(partyCode));
|
||||
if (party.isClosed()) {
|
||||
throw new PartyAlreadyClosedException(partyCode);
|
||||
}
|
||||
final BigDecimal amount = row.get("amount").getAsBigDecimal();
|
||||
final BigDecimal newBalance = party.credit(amount);
|
||||
agent.credit(amount);
|
||||
|
||||
transactions.add(new PartyTransaction(party, agent, transactionDate, "PARTY_TRAN", "CREDIT",
|
||||
stringOrNull(row, "transactionMode"), stringOrNull(row, "accountType"), stringOrNull(row, "accountNo"), amount,
|
||||
stringOrNull(row, "chequeNo"), stringOrNull(row, "tokenNo"), stringOrNull(row, "description"), newBalance));
|
||||
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 the customer's real account, and into the
|
||||
// Agent Cash account until the agent hands it over. A party opened before DDS began
|
||||
// opening real accounts has none, and is left on its DDS balance alone rather than the
|
||||
// whole round being refused because of one old record.
|
||||
if (party.getSavingsAccountId() != null) {
|
||||
this.ddsLedger.collect(party.getSavingsAccountId(), 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) {
|
||||
final BigDecimal amount = command.bigDecimalValueOfParameterNamed("amount");
|
||||
if (agent.getAgentCurrentBalance().compareTo(amount) < 0) {
|
||||
@@ -126,8 +270,12 @@ public class PartyTransactionWritePlatformServiceJpaRepositoryImpl implements Pa
|
||||
}
|
||||
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",
|
||||
command.stringValueOfParameterNamedAllowingNull("transactionMode"),
|
||||
DdsTransactionMode.canonicalOrOriginal(command.stringValueOfParameterNamedAllowingNull("transactionMode")),
|
||||
command.stringValueOfParameterNamedAllowingNull("accountType"),
|
||||
command.stringValueOfParameterNamedAllowingNull("accountNo"), amount, null, null,
|
||||
command.stringValueOfParameterNamedAllowingNull("description"), newBalance);
|
||||
@@ -140,10 +288,16 @@ public class PartyTransactionWritePlatformServiceJpaRepositoryImpl implements Pa
|
||||
final BigDecimal amount = command.bigDecimalValueOfParameterNamed("withdrawAmount");
|
||||
final String description = command.stringValueOfParameterNamedAllowingNull("withdrawDescription");
|
||||
|
||||
final Party creditorParty = this.partyRepository.findByPartyCode(creditorPartyCode)
|
||||
.orElseThrow(() -> new PartyNotFoundException(creditorPartyCode));
|
||||
final Party debitParty = this.partyRepository.findByPartyCode(debitPartyCode)
|
||||
.orElseThrow(() -> new PartyNotFoundException(debitPartyCode));
|
||||
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);
|
||||
@@ -152,6 +306,14 @@ public class PartyTransactionWritePlatformServiceJpaRepositoryImpl implements Pa
|
||||
final BigDecimal debtorNewBalance = debitParty.debit(amount);
|
||||
final BigDecimal creditorNewBalance = creditorParty.credit(amount);
|
||||
|
||||
// The same move on the bank's own books. Both parties need real accounts for it: moving one
|
||||
// side alone would leave the pair of DDS balances agreeing with each other and neither
|
||||
// agreeing with the bank, which is worse than not moving it at all.
|
||||
if (debitParty.getSavingsAccountId() != null && creditorParty.getSavingsAccountId() != null) {
|
||||
this.ddsLedger.transfer(debitParty.getSavingsAccountId(), creditorParty.getSavingsAccountId(), transactionDate, amount,
|
||||
description == null ? "DDS transfer " + debitPartyCode + " to " + creditorPartyCode : description);
|
||||
}
|
||||
|
||||
final List<PartyTransaction> transactions = new ArrayList<>();
|
||||
transactions.add(new PartyTransaction(debitParty, agent, transactionDate, "WITHDRAW_DEPOSITE", "DEBIT", null, null, null,
|
||||
amount, null, null, description, debtorNewBalance));
|
||||
@@ -160,6 +322,26 @@ public class PartyTransactionWritePlatformServiceJpaRepositoryImpl implements Pa
|
||||
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;
|
||||
}
|
||||
|
||||
+5
-2
@@ -19,6 +19,8 @@
|
||||
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;
|
||||
@@ -44,8 +46,9 @@ public class DdsPartyTransactionConfiguration {
|
||||
@ConditionalOnMissingBean(PartyTransactionWritePlatformService.class)
|
||||
public PartyTransactionWritePlatformService partyTransactionWritePlatformService(
|
||||
PartyTransactionCommandFromApiJsonDeserializer fromApiJsonDeserializer, PartyTransactionRepository partyTransactionRepository,
|
||||
PartyRepository partyRepository, AgentRepository agentRepository) {
|
||||
PartyRepository partyRepository, AgentRepository agentRepository, DdsLedger ddsLedger,
|
||||
WorkingDaysRepositoryWrapper workingDaysRepository) {
|
||||
return new PartyTransactionWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, partyTransactionRepository,
|
||||
partyRepository, agentRepository);
|
||||
partyRepository, agentRepository, ddsLedger, workingDaysRepository);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,4 +544,40 @@
|
||||
<include file="parts/0475_create_dds_commission_slab.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0476_create_dds_batch_run.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0477_add_dds_report_permission.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0478_create_dds_account_type.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0479_dds_account_type_delete_and_cleanup.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0480_restore_dds_account_type_post_day.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0481_trim_dds_agent_to_legacy_fields.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0482_add_dds_agent_audit_columns.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0483_add_dds_agent_is_active.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0484_backfill_dds_agent_created_date_utc.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0485_add_commission_slab_update_permission.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0486_add_dds_audit_columns.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0487_add_dds_stage6_columns.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0488_add_dds_auto_interest_job.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0489_add_dds_agent_close.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0490_add_dds_agent_office.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0491_add_dds_commission_expense_account.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0492_add_dds_deposit_product_setting.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0493_add_dds_agent_cash_account.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0494_add_dds_matured_payable_account.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0495_dds_collection_entry_no_maker_checker.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0496_grant_dds_permissions_to_branch_manager.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0497_backfill_dds_maturity_payout_ledger_rows.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0498_block_debits_on_dds_accounts.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0499_add_dds_batch_run_progress.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0500_allow_lien_on_dds_product.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0501_fix_dds_lien_limits.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0502_add_dds_premature_closure_settings.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0503_add_dds_premature_close_permission.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0504_add_dds_nominee_share.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0505_add_dds_nominee_sequence.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0506_add_dds_child_audit_columns.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0507_add_dds_party_office.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0508_add_dds_party_lien_reason.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0509_normalise_dds_transaction_mode.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0510_add_dds_maturity_auto_close.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0511_create_dds_collection_entry.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0512_dds_collection_entry_permissions.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0513_add_dds_agent_gl_account.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
DDS Account Type - the legacy "Account Type Creation" screen, which registers a DDS scheme
|
||||
variant (e.g. group "DDS", code "DS", "DAILY DEPOSIT") that Party accounts are opened under.
|
||||
|
||||
This is deliberately its own small master table rather than a Savings/Recurring Deposit
|
||||
product: DDS never used the deposit-product engine at all. Interest is accrued by the DDS
|
||||
batch job straight off the Party's own rate and balance (see BatchRun...ServiceImpl), and
|
||||
m_dds_party carries no product reference. Modelling this as an RD product only duplicated
|
||||
~40 product fields the legacy screen never asked for; a Party links to it by sub_type, the
|
||||
same way the legacy Party Master's "Sub Type" field did.
|
||||
-->
|
||||
<changeSet id="0478_create_dds_account_type" author="CBS">
|
||||
|
||||
<createTable tableName="m_dds_account_type">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="account_type_group" type="VARCHAR(60)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="account_type_code" type="VARCHAR(20)">
|
||||
<constraints nullable="false" unique="true" uniqueConstraintName="uq_dds_account_type_code"/>
|
||||
</column>
|
||||
<column name="account_type_description" type="VARCHAR(200)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="auto_interest_post" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="auto_interest_post_when" type="VARCHAR(20)"/>
|
||||
<column name="auto_interest_post_day" type="INT"/>
|
||||
<!-- Audit columns, filled automatically by AbstractAuditableCustom: who created/last
|
||||
changed this row and when. -->
|
||||
<column defaultValueComputed="NULL" name="createdby_id" type="BIGINT"/>
|
||||
<column defaultValueComputed="NULL" name="created_date" type="datetime"/>
|
||||
<column defaultValueComputed="NULL" name="lastmodifiedby_id" type="BIGINT"/>
|
||||
<column defaultValueComputed="NULL" name="lastmodified_date" type="datetime"/>
|
||||
</createTable>
|
||||
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="organisation"/>
|
||||
<column name="code" value="READ_DDSACCOUNTTYPE"/>
|
||||
<column name="entity_name" value="DDSACCOUNTTYPE"/>
|
||||
<column name="action_name" value="READ"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="organisation"/>
|
||||
<column name="code" value="CREATE_DDSACCOUNTTYPE"/>
|
||||
<column name="entity_name" value="DDSACCOUNTTYPE"/>
|
||||
<column name="action_name" value="CREATE"/>
|
||||
<column name="can_maker_checker" valueBoolean="true"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="organisation"/>
|
||||
<column name="code" value="CREATE_DDSACCOUNTTYPE_CHECKER"/>
|
||||
<column name="entity_name" value="DDSACCOUNTTYPE"/>
|
||||
<column name="action_name" value="CREATE_CHECKER"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="organisation"/>
|
||||
<column name="code" value="UPDATE_DDSACCOUNTTYPE"/>
|
||||
<column name="entity_name" value="DDSACCOUNTTYPE"/>
|
||||
<column name="action_name" value="UPDATE"/>
|
||||
<column name="can_maker_checker" valueBoolean="true"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="organisation"/>
|
||||
<column name="code" value="UPDATE_DDSACCOUNTTYPE_CHECKER"/>
|
||||
<column name="entity_name" value="DDSACCOUNTTYPE"/>
|
||||
<column name="action_name" value="UPDATE_CHECKER"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Drops auto_interest_post_day: the legacy Account Type Creation screen never asked for a
|
||||
posting day, and nothing reads it. Interest posting day is a scheme-wide convention
|
||||
(month end), not a per-account-type setting - if that ever needs to vary, it comes back
|
||||
as a deliberate field rather than an unused column.
|
||||
-->
|
||||
<changeSet id="0479_drop_dds_account_type_post_day" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<columnExists tableName="m_dds_account_type" columnName="auto_interest_post_day"/>
|
||||
</preConditions>
|
||||
<dropColumn tableName="m_dds_account_type" columnName="auto_interest_post_day"/>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
Retiring an account type is a deactivation, never a delete. Parties are opened against an
|
||||
account type, so removing the row would orphan every account already using it. Marking it
|
||||
inactive takes it out of the pick lists for new accounts while leaving existing accounts -
|
||||
and the record itself - intact, and it can be switched back on.
|
||||
-->
|
||||
<changeSet id="0479_add_dds_account_type_is_active" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_dds_account_type" columnName="is_active"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_dds_account_type">
|
||||
<column name="is_active" type="BOOLEAN" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Restores auto_interest_post_day, dropped in 0479 on the mistaken reading that the legacy
|
||||
Account Type Creation screen never had it. It does: the field is conditional, shown only
|
||||
once "Auto Interest Post" is set to Yes, which is why it was absent from the first
|
||||
screenshot of that screen. It holds the day of the month interest posts on.
|
||||
-->
|
||||
<changeSet id="0480_restore_dds_account_type_post_day" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_dds_account_type" columnName="auto_interest_post_day"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_dds_account_type">
|
||||
<column name="auto_interest_post_day" type="INT"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Trims m_dds_agent back to the legacy Agent Master Entry screen.
|
||||
|
||||
When SubAgent was folded into Agent (2026-08-24) its whole field set came across. Checking
|
||||
them against the legacy Agent Master screen and against the code showed these fifteen are on
|
||||
neither: they appear on no screen the business uses and no query, report or batch job reads
|
||||
them. They are dropped rather than left as dead columns; if TDS-on-commission, a transfer
|
||||
account or a security account is asked for later, each comes back deliberately with the
|
||||
logic that needs it.
|
||||
|
||||
Kept despite not being on that screen, because code does read them: home_branch (the batch
|
||||
job's branch filter - now filled from the client's office rather than typed), commission and
|
||||
last_commission_date (Commission Register, Agent Commission Statement), and agent_deposit /
|
||||
festival_advance / other_deduction (Agent Commission Statement). The last three have no entry
|
||||
point now that SubAgent's screen is gone, so they report as 0.00 - as most columns did in the
|
||||
legacy sample of that statement.
|
||||
-->
|
||||
<changeSet id="0481_drop_unused_dds_agent_columns" author="CBS">
|
||||
<dropColumn tableName="m_dds_agent">
|
||||
<column name="saving_branch"/>
|
||||
<column name="tds_applicable"/>
|
||||
<column name="tds_percent"/>
|
||||
<column name="current_tds"/>
|
||||
<column name="tds_amount"/>
|
||||
<column name="transfer_branch"/>
|
||||
<column name="transfer_amount_account_type"/>
|
||||
<column name="transfer_account_no"/>
|
||||
<column name="collector_commission_calculation"/>
|
||||
<column name="collector_limit_amount"/>
|
||||
<column name="machine_rent"/>
|
||||
<column name="agent_security_branch"/>
|
||||
<column name="agent_security_account_type"/>
|
||||
<column name="agent_security_account_no"/>
|
||||
<column name="other_commission"/>
|
||||
</dropColumn>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
The collection-limit history grid went with them: nothing read it, and it duplicated the
|
||||
single "Limit" column above it on the same screen.
|
||||
-->
|
||||
<changeSet id="0481_drop_dds_agent_limit_table" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<tableExists tableName="m_dds_agent_limit"/>
|
||||
</preConditions>
|
||||
<dropTable tableName="m_dds_agent_limit"/>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
home_branch is now derived from the agent's client's office instead of being typed, so it is
|
||||
no longer supplied by the caller and must tolerate a client with no office resolved.
|
||||
-->
|
||||
<changeSet id="0481_relax_dds_agent_home_branch" author="CBS">
|
||||
<dropNotNullConstraint tableName="m_dds_agent" columnName="home_branch" columnDataType="VARCHAR(100)"/>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Who created or last changed an agent, and when - shown on the agent list so a record says
|
||||
where it came from without going to the audit trail. Filled automatically by
|
||||
AbstractAuditableCustom.
|
||||
-->
|
||||
<changeSet id="0482_add_dds_agent_audit_columns" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_dds_agent" columnName="created_date"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_dds_agent">
|
||||
<column defaultValueComputed="NULL" name="createdby_id" type="BIGINT"/>
|
||||
<column defaultValueComputed="NULL" name="created_date" type="datetime"/>
|
||||
<column defaultValueComputed="NULL" name="lastmodifiedby_id" type="BIGINT"/>
|
||||
<column defaultValueComputed="NULL" name="lastmodified_date" type="datetime"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
Agents registered before those columns existed would show a blank Created for ever. Their
|
||||
real creation time is already on record - every agent was registered through a CREATE AGENT
|
||||
command, which the audit trail stamps with who ran it and when - so it is recovered from
|
||||
there rather than left empty or faked with the migration's own run time.
|
||||
-->
|
||||
<changeSet id="0482_backfill_dds_agent_created_date" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<columnExists tableName="m_dds_agent" columnName="created_date"/>
|
||||
</preConditions>
|
||||
<sql>
|
||||
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
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
An agent who stops collecting is deactivated, never deleted: every party collection,
|
||||
settlement and commission posting is recorded against the agent, so removing the row would
|
||||
orphan its whole history and the reports built on it. Marking it inactive takes it out of
|
||||
the pick lists for new collection work while leaving the record and its history intact, and
|
||||
it can be switched back on - the same treatment m_dds_account_type gets in 0479.
|
||||
-->
|
||||
<changeSet id="0483_add_dds_agent_is_active" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_dds_agent" columnName="is_active"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_dds_agent">
|
||||
<column name="is_active" type="BOOLEAN" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Redoes the backfill in 0482, which read m_portfolio_command_source.made_on_date and so filled
|
||||
in nothing: commands record their timestamp in made_on_date_utc, and made_on_date is left
|
||||
null. Both are read here so the backfill works whichever column a tenant's history carries.
|
||||
|
||||
0482's changeset cannot simply be corrected in place - it has already run, and Liquibase
|
||||
checksums a changeset once it has - so the fix arrives as its own changeset. It only touches
|
||||
rows still missing a created_date, which makes it a no-op wherever 0482 or an earlier run of
|
||||
this already did the job.
|
||||
-->
|
||||
<changeSet id="0484_backfill_dds_agent_created_date_utc" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<columnExists tableName="m_dds_agent" columnName="created_date"/>
|
||||
</preConditions>
|
||||
<sql>
|
||||
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
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
A commission slab could be added but never corrected - a mistyped percentage or range meant
|
||||
living with it, since the chart had no update path at all. The legacy Commission Slab Master
|
||||
offered an edit on every saved row, so this restores that.
|
||||
|
||||
Maker-checker on, matching CREATE_COMMISSIONSLAB: changing a slab changes what every agent is
|
||||
paid, so it is the same class of decision as adding one.
|
||||
-->
|
||||
<changeSet id="0485_add_commission_slab_update_permission" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">
|
||||
select count(*) from m_permission where code = 'UPDATE_COMMISSIONSLAB'
|
||||
</sqlCheck>
|
||||
</preConditions>
|
||||
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="organisation"/>
|
||||
<column name="code" value="UPDATE_COMMISSIONSLAB"/>
|
||||
<column name="entity_name" value="COMMISSIONSLAB"/>
|
||||
<column name="action_name" value="UPDATE"/>
|
||||
<column name="can_maker_checker" valueBoolean="true"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="organisation"/>
|
||||
<column name="code" value="UPDATE_COMMISSIONSLAB_CHECKER"/>
|
||||
<column name="entity_name" value="COMMISSIONSLAB"/>
|
||||
<column name="action_name" value="UPDATE_CHECKER"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Who created or last changed a row, and when. m_dds_account_type (0478) and m_dds_agent (0482)
|
||||
already carry these; the four tables here were left without them.
|
||||
|
||||
Only tables whose rows are created on their own get them. m_dds_agent_guarantor,
|
||||
m_dds_party_nominee and m_dds_batch_run_row are always written as part of their parent, so the
|
||||
parent's audit already answers who and when for them.
|
||||
-->
|
||||
|
||||
<!--
|
||||
A commission slab decides what every agent is paid, and since 0485 it can be corrected as well
|
||||
as added. Without this there is no way to tell who changed a rate, or when.
|
||||
-->
|
||||
<changeSet id="0486_add_dds_commission_slab_audit_columns" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_dds_commission_slab" columnName="created_date"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_dds_commission_slab">
|
||||
<column defaultValueComputed="NULL" name="createdby_id" type="BIGINT"/>
|
||||
<column defaultValueComputed="NULL" name="created_date" type="datetime"/>
|
||||
<column defaultValueComputed="NULL" name="lastmodifiedby_id" type="BIGINT"/>
|
||||
<column defaultValueComputed="NULL" name="lastmodified_date" type="datetime"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<!-- The customer's DDS account: who opened it, and who last amended it. -->
|
||||
<changeSet id="0486_add_dds_party_audit_columns" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_dds_party" columnName="created_date"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_dds_party">
|
||||
<column defaultValueComputed="NULL" name="createdby_id" type="BIGINT"/>
|
||||
<column defaultValueComputed="NULL" name="created_date" type="datetime"/>
|
||||
<column defaultValueComputed="NULL" name="lastmodifiedby_id" type="BIGINT"/>
|
||||
<column defaultValueComputed="NULL" name="lastmodified_date" type="datetime"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
transaction_date is the business date - the day the money belongs to - which is not the same
|
||||
as the moment the entry was keyed. A collection posted today against last month's date looks
|
||||
identical to one posted last month, and that difference is exactly what a query into a
|
||||
disputed or back-dated entry turns on.
|
||||
-->
|
||||
<changeSet id="0486_add_dds_party_transaction_audit_columns" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_dds_party_transaction" columnName="created_date"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_dds_party_transaction">
|
||||
<column defaultValueComputed="NULL" name="createdby_id" type="BIGINT"/>
|
||||
<column defaultValueComputed="NULL" name="created_date" type="datetime"/>
|
||||
<column defaultValueComputed="NULL" name="lastmodifiedby_id" type="BIGINT"/>
|
||||
<column defaultValueComputed="NULL" name="lastmodified_date" type="datetime"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<!-- A batch run posts interest and commission to real accounts; who set it off has to be on record. -->
|
||||
<changeSet id="0486_add_dds_batch_run_audit_columns" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_dds_batch_run" columnName="created_date"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_dds_batch_run">
|
||||
<column defaultValueComputed="NULL" name="createdby_id" type="BIGINT"/>
|
||||
<column defaultValueComputed="NULL" name="created_date" type="datetime"/>
|
||||
<column defaultValueComputed="NULL" name="lastmodifiedby_id" type="BIGINT"/>
|
||||
<column defaultValueComputed="NULL" name="lastmodified_date" type="datetime"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
What the Interest/Commission batch needs to pay an agent the right amount.
|
||||
|
||||
0481 trimmed m_dds_agent down to the fields the Agent Master screen shows, on the grounds that
|
||||
nothing read the rest. That was right for every field it dropped but two: the legacy SubAgent
|
||||
Master screen carries TDS/TDS% and a "Com. Calculation" switch, and the Agent Commission
|
||||
Statement report subtracts a "Deduct Commi." figure. All three are inputs to the commission
|
||||
run, so they come back here - and only these three, not the rest of what 0481 removed.
|
||||
-->
|
||||
<changeSet id="0487_add_dds_agent_commission_fields" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_dds_agent" columnName="commission_calculation"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_dds_agent">
|
||||
<!--
|
||||
Whether this agent earns commission at all. The legacy screen defaults it to YES, and
|
||||
every agent that exists today was created while commission was unconditional, so
|
||||
defaulting to true keeps their behaviour unchanged.
|
||||
-->
|
||||
<column name="commission_calculation" type="BOOLEAN" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- TDS is deducted from commission at source. Off unless the bank turns it on per agent. -->
|
||||
<column name="tds_applicable" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="tds_percent" type="DECIMAL(19,6)" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Running total of TDS withheld, mirroring how `commission` accumulates. -->
|
||||
<column name="tds_amount" type="DECIMAL(19,6)" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- The Agent Commission Statement's "Deduct Commi." column, alongside the agent_deposit,
|
||||
festival_advance and other_deduction figures that table already has. -->
|
||||
<column name="deduct_commission" type="DECIMAL(19,6)" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
m_dds_party already carries interest_calc and interest_date - interest worked out but not yet
|
||||
credited, and the date it is worked out through. OD interest is a second, separate stream over
|
||||
the same account (the legacy Interest Part menu runs and posts it independently of regular
|
||||
interest), so it needs its own pair. Sharing one pair would let an OD run move the regular
|
||||
run's through-date, and an OD posting credit interest a regular run had accrued.
|
||||
-->
|
||||
<changeSet id="0487_add_dds_party_od_interest_columns" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_dds_party" columnName="od_interest_calc"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_dds_party">
|
||||
<column name="od_interest_calc" type="DECIMAL(19,6)" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="od_interest_date" type="DATE"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
A batch row currently records only the net figure. For interest that is enough, but a
|
||||
commission row is the end of a calculation with six inputs, and an agent querying their payout
|
||||
needs to see how it was arrived at - which is exactly what the Agent Commission Statement
|
||||
prints. Storing the breakdown on the row keeps the answer as it was on the day it ran: the
|
||||
slab chart and the deduction figures both move over time, so recomputing it later would not
|
||||
reproduce it.
|
||||
|
||||
Nullable because interest rows leave them empty.
|
||||
-->
|
||||
<changeSet id="0487_add_dds_batch_run_row_breakdown" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_dds_batch_run_row" columnName="gross_amount"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_dds_batch_run_row">
|
||||
<!-- Commission: the collection volume the slab was matched on. Interest: the daily-product
|
||||
base, i.e. balance x days, which is what the interest was worked out from. -->
|
||||
<column name="base_amount" type="DECIMAL(19,6)"/>
|
||||
<column name="rate_percent" type="DECIMAL(19,6)"/>
|
||||
<!-- The amount before any deduction. For interest rows this equals `amount`. -->
|
||||
<column name="gross_amount" type="DECIMAL(19,6)"/>
|
||||
<column name="tds_amount" type="DECIMAL(19,6)"/>
|
||||
<column name="deduction_amount" type="DECIMAL(19,6)"/>
|
||||
<!-- The period this row covers, so a row can be read without knowing what the previous
|
||||
run's through-date was. -->
|
||||
<column name="from_date" type="DATE"/>
|
||||
<column name="to_date" type="DATE"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Makes the Account Type screen's "Auto Interest Post" setting do what it says.
|
||||
|
||||
Until now the screen stored a frequency and a posting day that nothing read, so interest had
|
||||
to be posted by hand every month. These three changesets add the scheduled job that reads it,
|
||||
the run scope it needs, and a posting day for the account types that were saved without one.
|
||||
-->
|
||||
|
||||
<!--
|
||||
Which scheme a run covered.
|
||||
|
||||
A posting day belongs to an account type, not to the bank as a whole - one scheme can post on
|
||||
the 15th and another at month end. A run therefore has to be able to say "this covered the
|
||||
DAILY DEPOSIT scheme only", or the 15th's run would post the month-end scheme's interest too.
|
||||
|
||||
Null means every scheme, which is what a run started by hand from the screen does.
|
||||
-->
|
||||
<changeSet id="0488_add_dds_batch_run_sub_type" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_dds_batch_run" columnName="sub_type"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_dds_batch_run">
|
||||
<column name="sub_type" type="VARCHAR(60)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
Account types that say "post automatically" but carry no day.
|
||||
|
||||
The screen only began requiring a day later, so rows saved before that have none - and a
|
||||
scheduled job cannot act on "monthly, unspecified day". They are set to 31, which the job
|
||||
reads as the last day of whichever month it is running in: 31 January, 28 February, 30 April.
|
||||
That was the stated intent for these schemes, and it is now recorded rather than assumed.
|
||||
-->
|
||||
<changeSet id="0488_backfill_dds_auto_interest_post_day" author="CBS">
|
||||
<update tableName="m_dds_account_type">
|
||||
<column name="auto_interest_post_day" valueNumeric="31"/>
|
||||
<where>auto_interest_post = true and auto_interest_post_day is null</where>
|
||||
</update>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
The job itself. Runs at 00:20 daily - after the business date rolls over at midnight and after
|
||||
the savings interest job at 00:00, so a DDS posting is never worked out against a stale date.
|
||||
It does nothing on a day no scheme is due, which is most days.
|
||||
-->
|
||||
<changeSet id="0488_insert_dds_post_interest_job" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">select count(*) from job where name = 'Post DDS Interest'</sqlCheck>
|
||||
</preConditions>
|
||||
<!--
|
||||
The id is worked out rather than left to the column's identity sequence. Every job in this
|
||||
table was seeded with an explicit id, which leaves the sequence sitting at 1 while ids up
|
||||
to 50 are already taken - so letting it generate one collides with an existing row.
|
||||
The sequence is put back in step afterwards, so the next job added anywhere gets a free id.
|
||||
-->
|
||||
<sql>
|
||||
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;
|
||||
</sql>
|
||||
<sql>
|
||||
select setval(pg_get_serial_sequence('job', 'id'), (select max(id) from job));
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Closing an agent, as distinct from switching one off.
|
||||
|
||||
The legacy SubAgent Master screen carried a Close Account button alongside Save - two separate
|
||||
ideas that this table only had one of. Deactivating has to be immediate, because it is how the
|
||||
bank stops an agent collecting; closing is the end of the relationship and cannot happen while
|
||||
the agent still holds cash, is still owed commission, or still has accounts assigned to them.
|
||||
|
||||
m_dds_party already works this way: an account is settled to zero and then closed. This brings
|
||||
the agent in line with it.
|
||||
-->
|
||||
<changeSet id="0489_add_dds_agent_close_columns" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_dds_agent" columnName="close_date"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_dds_agent">
|
||||
<!--
|
||||
Null while the agent is on the books. A closed agent is never deleted - every
|
||||
collection, settlement and commission posting is recorded against it.
|
||||
-->
|
||||
<column name="close_date" type="DATE"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0489_add_dds_agent_close_permission" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">select count(*) from m_permission where code = 'CLOSE_AGENT'</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="organisation"/>
|
||||
<column name="code" value="CLOSE_AGENT"/>
|
||||
<column name="entity_name" value="AGENT"/>
|
||||
<column name="action_name" value="CLOSE"/>
|
||||
<column name="can_maker_checker" valueBoolean="true"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="organisation"/>
|
||||
<column name="code" value="CLOSE_AGENT_CHECKER"/>
|
||||
<column name="entity_name" value="AGENT"/>
|
||||
<column name="action_name" value="CLOSE_CHECKER"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Which branch an agent belongs to, recorded as a real office rather than that office's name.
|
||||
|
||||
home_branch was a varchar holding a branch name, matched by name wherever it was read - the
|
||||
batch run's branch filter and the branch column of several reports. Three things follow from
|
||||
storing a name: renaming an office silently orphans every agent under it, two offices sharing
|
||||
a name cannot be told apart, and nothing checks that the name is an office at all.
|
||||
|
||||
home_branch is deliberately kept and kept in step with the office's name, so the existing
|
||||
report SQL and batch filter keep working unchanged while the id becomes the thing of record.
|
||||
-->
|
||||
<changeSet id="0490_add_dds_agent_office_id" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_dds_agent" columnName="office_id"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_dds_agent">
|
||||
<column name="office_id" type="BIGINT"/>
|
||||
</addColumn>
|
||||
<addForeignKeyConstraint baseTableName="m_dds_agent" baseColumnNames="office_id"
|
||||
constraintName="fk_m_dds_agent_office"
|
||||
referencedTableName="m_office" referencedColumnNames="id"/>
|
||||
<createIndex tableName="m_dds_agent" indexName="idx_m_dds_agent_office_id">
|
||||
<column name="office_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
Backfill in two passes. The name match is tried first because home_branch is what the agent
|
||||
was actually filed under; the client's own office is the fallback for a name that matches no
|
||||
office - which is exactly the case this column exists to make impossible from now on.
|
||||
-->
|
||||
<changeSet id="0490_backfill_dds_agent_office_from_home_branch" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">select count(*) from m_dds_agent where office_id is not null</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
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;
|
||||
</sql>
|
||||
<sql>
|
||||
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;
|
||||
</sql>
|
||||
<!-- And put the office's own name back into home_branch, so the two cannot disagree. -->
|
||||
<sql>
|
||||
update m_dds_agent a set home_branch = o.name
|
||||
from m_office o
|
||||
where o.id = a.office_id;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
The expense an agent's commission is, and the setting that points the commission run at it.
|
||||
|
||||
Paying commission credits the agent's savings account. Left to the savings module alone the
|
||||
other half of that entry reads "cash came into the vault", because that is what a deposit
|
||||
normally means - but no cash arrived here: the bank incurred an expense. So the commission run
|
||||
posts the pair itself, and this is the account it debits.
|
||||
|
||||
Held as a setting rather than a constant in the code because a bank's chart of accounts is the
|
||||
bank's, not ours: a bank that already has a commission expense account points this at theirs.
|
||||
-->
|
||||
<changeSet id="0491_add_dds_commission_expense_gl_account" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">select count(*) from acc_gl_account where gl_code = '11.2'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
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.';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0491_add_dds_commission_expense_configuration" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">
|
||||
select count(*) from c_configuration where name = 'dds-agent-commission-expense-account'
|
||||
</sqlCheck>
|
||||
</preConditions>
|
||||
<!--
|
||||
value holds the gl account's id, looked up by code rather than written in, so this lands on
|
||||
the right account whatever id it happens to have on a given database.
|
||||
-->
|
||||
<sql>
|
||||
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.';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Which savings product a DDS customer's real account is opened under.
|
||||
|
||||
DDS kept its own balance and nothing else: a customer's money was a number on m_dds_party and
|
||||
never an account the bank's books knew about. Opening a real account behind each party is what
|
||||
changes that, and this names the product it is opened under.
|
||||
|
||||
A savings product rather than a recurring one, deliberately. A recurring product expects a
|
||||
fixed instalment schedule - "the 5th of every month, this amount" - and reads every date that
|
||||
does not match as overdue. Daily collection is the opposite: whatever the agent collects, on
|
||||
whatever days they call. A savings account takes any amount on any day, which is the shape of
|
||||
the thing being recorded.
|
||||
|
||||
A setting rather than a constant so a bank points this at a product of their own making. The
|
||||
product is not created here - a bank's products are set up by the bank, with their own rates,
|
||||
charges and ledger accounts - so this only records which one DDS should use.
|
||||
-->
|
||||
<changeSet id="0492_add_dds_deposit_product_configuration" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">select count(*) from c_configuration where name = 'dds-deposit-product'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
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.';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
The bank's cash that is out with an agent, and the setting that points DDS at it.
|
||||
|
||||
An agent collects all morning and reaches a branch in the afternoon. In between, the money is
|
||||
the bank's and the customers have already been credited for it - but it is in a bag, not in a
|
||||
till. Somewhere has to hold it, or the balance sheet is short by whatever the agents are
|
||||
carrying.
|
||||
|
||||
m_dds_agent.agent_current_balance already tracks exactly this, per agent. What it never was is
|
||||
a ledger account, so the figure existed inside DDS and nowhere the bank's books could see. The
|
||||
two now move together: the column says which agent, this account says how much in total.
|
||||
-->
|
||||
<changeSet id="0493_add_dds_agent_cash_gl_account" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">select count(*) from acc_gl_account where gl_code = '2.1'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
insert into acc_gl_account (name, parent_id, hierarchy, gl_code, disabled, manual_journal_entries_allowed,
|
||||
account_usage, classification_enum, description)
|
||||
values ('DDS Agent 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.');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0493_add_dds_agent_cash_configuration" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">select count(*) from c_configuration where name = 'dds-agent-cash-account'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
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.';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Where a matured deposit waits when it is going to an account at another bank.
|
||||
|
||||
A customer whose only other account is elsewhere is paid by NEFT or IMPS, and that leaves the
|
||||
bank in stages: the money comes off their DDS account today, and the transfer clears later - or
|
||||
fails and has to be tried again. It cannot simply be sent and forgotten about, because a
|
||||
failed transfer would leave money that has left one account and arrived at none.
|
||||
|
||||
So the payout moves it here first. This account says, at any moment, exactly how much matured
|
||||
money the bank is holding but has not yet handed over - and the transfer is made from here.
|
||||
Every core system does this; the alternative is money in flight that nothing accounts for.
|
||||
-->
|
||||
<changeSet id="0494_add_dds_matured_payable_gl_account" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">select count(*) from acc_gl_account where gl_code = '9.6'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
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.';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0494_add_dds_matured_payable_configuration" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">
|
||||
select count(*) from c_configuration where name = 'dds-matured-payable-account'
|
||||
</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
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.';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
Paying a matured account out is its own permission, not part of closing one. It is the action
|
||||
that moves the money, so it is the one a bank may want a second person to approve.
|
||||
-->
|
||||
<changeSet id="0494_add_dds_party_payout_permission" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">select count(*) from m_permission where code = 'PAYOUT_PARTY'</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="portfolio"/>
|
||||
<column name="code" value="PAYOUT_PARTY"/>
|
||||
<column name="entity_name" value="PARTY"/>
|
||||
<column name="action_name" value="PAYOUT"/>
|
||||
<column name="can_maker_checker" valueBoolean="true"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="portfolio"/>
|
||||
<column name="code" value="PAYOUT_PARTY_CHECKER"/>
|
||||
<column name="entity_name" value="PARTY"/>
|
||||
<column name="action_name" value="PAYOUT_CHECKER"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Takes the daily collection entry out of the maker-checker queue.
|
||||
|
||||
Every DDS permission was created maker-checker eligible, which puts the agent's daily
|
||||
collection behind an approval. A branch collecting from several hundred customers would need
|
||||
that many approvals a day, every day, and an approval nobody has time to give is not a
|
||||
control - it is a queue that either stops the branch or gets waved through unread.
|
||||
|
||||
The bank does not ask for one on the equivalent counter work either: DEPOSIT_SAVINGSACCOUNT,
|
||||
WITHDRAWAL_SAVINGSACCOUNT and DISBURSE_LOAN all sit outside maker-checker here, while CLOSE,
|
||||
APPROVE and POSTINTEREST sit inside it. A daily collection belongs with the first group.
|
||||
|
||||
Everything else DDS does keeps its approval: opening and editing a party or an agent, the
|
||||
schemes and commission slabs, the maturity payout, both closures, and the batch posting run.
|
||||
|
||||
One caveat worth recording. CREATE_PARTYTRANSACTION covers three entries, not one - the daily
|
||||
collection, an agent settling collected cash to a branch, and a transfer between two parties.
|
||||
They cannot be told apart at the permission level, so the transfer loses its approval
|
||||
alongside the collection. Splitting the permission is the fix, and is left to a change of its
|
||||
own rather than smuggled into this one.
|
||||
-->
|
||||
<changeSet id="0495_dds_collection_entry_no_maker_checker" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="1">
|
||||
select count(*) from m_permission
|
||||
where code = 'CREATE_PARTYTRANSACTION' and can_maker_checker = true
|
||||
</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
update m_permission set can_maker_checker = false where code = 'CREATE_PARTYTRANSACTION';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Gives the Branch Manager role the DDS permissions.
|
||||
|
||||
Every DDS permission existed but belonged to no role, so the module could only be worked by a
|
||||
superuser - which also meant maker-checker had never actually run against it. A superuser holds
|
||||
ALL_FUNCTIONS, and AppUser.hasPermissionTo answers true for anything on that basis, so such a
|
||||
user is treated as a checker superuser and approves their own commands on the spot. Every DDS
|
||||
command on file so far shows the same name as maker and as checker.
|
||||
|
||||
Branch Manager does not hold ALL_FUNCTIONS, so the same commands from that role take the
|
||||
ordinary route: they wait for approval, and with enable-same-maker-checker off, the person who
|
||||
raised one cannot be the person who approves it.
|
||||
|
||||
The whole set is granted, checker permissions included, because a manager both works the
|
||||
module and approves what a clerk sends up. Which roles a bank ultimately gives DDS to is its
|
||||
own decision; this makes the module usable by somebody other than the superuser, and gives
|
||||
maker-checker a role it can actually be exercised on.
|
||||
|
||||
The Clerk role is deliberately untouched. It carries ALL_FUNCTIONS, which would let it approve
|
||||
its own work - and anyone else's, a junior clearing a manager's payout among them. That is a
|
||||
property of how that role is set up across the whole bank, not of DDS, and correcting it
|
||||
affects loans, savings and cash alike. It is for the bank to decide, not for this changeset.
|
||||
-->
|
||||
<changeSet id="0496_grant_dds_permissions_to_branch_manager" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="1">select count(*) from m_role where name = 'Branch Manager'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
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);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Puts the maturity payouts already made onto the scheme's own ledger.
|
||||
|
||||
The payout moved the money and wrote it to the savings account and to the general ledger, but
|
||||
not to m_dds_party_transaction. An account paid out before this ran therefore reads as a run
|
||||
of collections and then a nil balance, with nothing to say where the money went - and the
|
||||
statement a customer is handed would be missing the one line they care about most.
|
||||
|
||||
The rows are not invented. Each is read back from the journal entry the payout itself posted,
|
||||
which names the party in its description, so the date and the amount are the ones already on
|
||||
the bank's books. Only the debit leg is taken, since a payout posts a pair and either side
|
||||
would otherwise produce a duplicate; reversed entries are skipped.
|
||||
|
||||
The journal entry is the trace to use rather than the savings withdrawal: a payout into the
|
||||
customer's own account links its entry to the receiving deposit, not to the withdrawal, and a
|
||||
payout parked in sundry links to neither. The description carries the party either way.
|
||||
|
||||
Where the money went is not recoverable from that trace, so the description says the row was
|
||||
reconstructed rather than claiming a destination it cannot know.
|
||||
|
||||
Nothing to do on a bank that has not paid a DDS maturity out yet; the select simply returns
|
||||
no rows. Payouts made from here on write their own row and never reach this.
|
||||
-->
|
||||
<changeSet id="0497_backfill_dds_maturity_payout_ledger_rows" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<tableExists tableName="m_dds_party_transaction"/>
|
||||
</preConditions>
|
||||
<sql>
|
||||
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');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Blocks withdrawals on the DDS accounts already open.
|
||||
|
||||
Accounts opened from here on are blocked as they are created. These predate that, and without
|
||||
it a customer can empty a twelve-month scheme at the counter the day after opening it - the
|
||||
one thing daily deposit saving exists to prevent. A withdrawal made that way also happens
|
||||
outside DDS entirely, so the scheme balance and the account balance part company, the reports
|
||||
misstate the book, and the maturity payout later fails for want of funds.
|
||||
|
||||
600 is BLOCK_DEBIT: deposits still land, so the agent collects and interest posts exactly as
|
||||
before. Only the way out is closed, and only from outside - DDS lifts it for its own payouts
|
||||
and transfers, and will lift it for a premature closure once that is built.
|
||||
|
||||
Only accounts on the DDS product, only those belonging to a party still open, and only where
|
||||
nothing is set already - a dormant or otherwise flagged account keeps the sub-status it has
|
||||
rather than having it quietly overwritten here.
|
||||
-->
|
||||
<changeSet id="0498_block_debits_on_dds_accounts" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="1">
|
||||
select count(*) from c_configuration where name = 'dds-deposit-product' and enabled = true
|
||||
</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
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');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Lets a posting run report how far it has got, and why it stopped if it did.
|
||||
|
||||
Posting credits each account one at a time, and each credit carries the full cost of a savings
|
||||
deposit - the account is loaded with its transactions and its running balances worked out
|
||||
again. Measured here, four hundred accounts carrying ninety days of collections each 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 a clerk watching a still screen with no way to
|
||||
tell a slow run from a hung one.
|
||||
|
||||
So the run moves to the background and these two say what it is doing. rows_posted is what a
|
||||
screen reads to show progress; failure_reason holds what went wrong, because a run that dies
|
||||
in the background has nowhere else to say so - and a clerk who is not told will simply run it
|
||||
again.
|
||||
|
||||
The arithmetic itself is untouched. Nothing about which account is credited, or with how much,
|
||||
changes here.
|
||||
-->
|
||||
<changeSet id="0499_add_dds_batch_run_progress" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_dds_batch_run" columnName="rows_posted"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_dds_batch_run">
|
||||
<column name="rows_posted" type="INT" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="failure_reason" type="VARCHAR(500)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Lets a DDS deposit stand as security for a loan.
|
||||
|
||||
A customer who needs money before their term ends has otherwise to break the scheme, losing
|
||||
the interest they have built up and the habit of saving with it. Lending against the deposit
|
||||
keeps both: the collection carries on, and what was lent is held on the account so it cannot
|
||||
walk out of the door while the loan is still owed.
|
||||
|
||||
The bank makes the loan through its own lending, on a product of its own with its own rate.
|
||||
This only lets the deposit be pledged - and the payout, which now refuses while anything is
|
||||
held, is what makes the pledge mean something.
|
||||
|
||||
No ceiling is set. The amount that may be lent against a deposit is decided case by case on
|
||||
the bank's own approval, so a figure written in here would only be a guess standing in the way
|
||||
of a decision that has already been taken properly.
|
||||
-->
|
||||
<changeSet id="0500_allow_lien_on_dds_product" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="1">
|
||||
select count(*) from c_configuration where name = 'dds-deposit-product' and enabled = true
|
||||
</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
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);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user